Move compound-ios package into the project.

This commit is contained in:
Doug
2025-09-28 15:19:46 +01:00
committed by Doug
parent 0519ef9c9b
commit aeaac90239
80 changed files with 3658 additions and 25 deletions

3
.gitattributes vendored
View File

@@ -1,4 +1,5 @@
UITests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text
DevelopmentAssets/Media/** filter=lfs diff=lfs merge=lfs -text
UnitTests/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text
PreviewTests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text
PreviewTests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text
compound-ios/Tests/CompoundTests/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text

View File

@@ -1612,6 +1612,7 @@
16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = "<group>"; };
16D353E10A64172D863769BF /* TombstonedAvatarImage.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TombstonedAvatarImage.swift; sourceTree = "<group>"; };
1715E3D7F53C0748AA50C91C /* PostHogAnalyticsClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogAnalyticsClient.swift; sourceTree = "<group>"; };
174E4AEF3DED300AA81046EC /* compound-ios */ = {isa = PBXFileReference; lastKnownFileType = folder; name = "compound-ios"; path = "compound-ios"; sourceTree = SOURCE_ROOT; };
17A8AA0DFA06012A9DAB951E /* TimelineProxyMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimelineProxyMock.swift; sourceTree = "<group>"; };
17BAE25A0E9E9F2F1BBA8930 /* DeactivateAccountScreenViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DeactivateAccountScreenViewModel.swift; sourceTree = "<group>"; };
181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePreferenceTests.swift; sourceTree = "<group>"; };
@@ -3825,6 +3826,7 @@
A8002CB4F20B6282850A614C /* DevelopmentAssets */,
2197234282B4BC0CE79AAC74 /* Secrets */,
823ED0EC3F1B6CF47D284011 /* Tools */,
9413F680ECDFB2B0DDB0DEF2 /* Packages */,
681566846AF307E9BA4C72C6 /* Products */,
);
sourceTree = "<group>";
@@ -5144,6 +5146,14 @@
path = UserProfileScreen;
sourceTree = "<group>";
};
9413F680ECDFB2B0DDB0DEF2 /* Packages */ = {
isa = PBXGroup;
children = (
174E4AEF3DED300AA81046EC /* compound-ios */,
);
name = Packages;
sourceTree = "<group>";
};
948DD12A5533BE1BC260E437 /* LocationSharing */ = {
isa = PBXGroup;
children = (
@@ -6871,7 +6881,6 @@
AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */,
4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */,
F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */,
4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */,
C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */,
D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */,
@@ -6894,6 +6903,7 @@
6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */,
EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */,
EE40B0E16A55BD23ECBFFD22 /* XCRemoteSwiftPackageReference "matrix-rich-text-editor-swift" */,
C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */,
);
preferredProjectObjectVersion = 77;
projectDirPath = "";
@@ -9285,6 +9295,13 @@
};
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = "compound-ios";
};
/* End XCLocalSwiftPackageReference section */
/* Begin XCRemoteSwiftPackageReference section */
0CBF57301AA172C21F76CE86 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = {
isa = XCRemoteSwiftPackageReference;
@@ -9486,14 +9503,6 @@
version = 2.37.12;
};
};
F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/element-hq/compound-ios";
requirement = {
kind = revision;
revision = 16f4544d2823d590401f13da260e56b8674b66b3;
};
};
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = {
isa = XCRemoteSwiftPackageReference;
repositoryURL = "https://github.com/apple/swift-collections";
@@ -9512,7 +9521,6 @@
};
07FEEEDB11543A7DED420F04 /* Compound */ = {
isa = XCSwiftPackageProductDependency;
package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */;
productName = Compound;
};
0DD568A494247444A4B56031 /* Kingfisher */ = {
@@ -9577,7 +9585,6 @@
};
3262F08E1C3483C22A7A319F /* Compound */ = {
isa = XCSwiftPackageProductDependency;
package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */;
productName = Compound;
};
32B8F4CD937AA9C1F8FC3CBC /* KeychainAccess */ = {
@@ -9832,7 +9839,6 @@
};
DCA3C4A997AD28E6918D4CE5 /* Compound */ = {
isa = XCSwiftPackageProductDependency;
package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */;
productName = Compound;
};
DE8DC9B3FBA402117DC4C49F /* Kingfisher */ = {

View File

@@ -1,5 +1,5 @@
{
"originHash" : "9d89a9320c29b16ca97c3b4c1dce15f8ab13ae1a66da50c98eec015db72c30d1",
"originHash" : "123c89d756b94a4cfdf4ca86fa4278640fc96854ac223bcef68e5566b0c47018",
"pins" : [
{
"identity" : "compound-design-tokens",
@@ -10,14 +10,6 @@
"version" : "6.0.0"
}
},
{
"identity" : "compound-ios",
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/compound-ios",
"state" : {
"revision" : "16f4544d2823d590401f13da260e56b8674b66b3"
}
},
{
"identity" : "devicekit",
"kind" : "remoteSourceControl",

View File

@@ -0,0 +1,68 @@
{
"pins" : [
{
"identity" : "compound-design-tokens",
"kind" : "remoteSourceControl",
"location" : "https://github.com/element-hq/compound-design-tokens",
"state" : {
"revision" : "be5d26dfd4ad659b0d3b3aec1ad1cccd0dc8d063",
"version" : "6.0.0"
}
},
{
"identity" : "sfsafesymbols",
"kind" : "remoteSourceControl",
"location" : "https://github.com/SFSafeSymbols/SFSafeSymbols",
"state" : {
"revision" : "3dd282d3269b061853a3b3bcd23a509d2aa166ce",
"version" : "6.2.0"
}
},
{
"identity" : "swift-custom-dump",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-custom-dump",
"state" : {
"revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1",
"version" : "1.3.3"
}
},
{
"identity" : "swift-snapshot-testing",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/swift-snapshot-testing",
"state" : {
"revision" : "1be8144023c367c5de701a6313ed29a3a10bf59b",
"version" : "1.18.3"
}
},
{
"identity" : "swift-syntax",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-syntax",
"state" : {
"revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2",
"version" : "601.0.1"
}
},
{
"identity" : "swiftui-introspect",
"kind" : "remoteSourceControl",
"location" : "https://github.com/siteline/SwiftUI-Introspect",
"state" : {
"revision" : "a08b87f96b41055577721a6e397562b21ad52454",
"version" : "26.0.0"
}
},
{
"identity" : "xctest-dynamic-overlay",
"kind" : "remoteSourceControl",
"location" : "https://github.com/pointfreeco/xctest-dynamic-overlay",
"state" : {
"revision" : "b2ed9eabefe56202ee4939dd9fc46b6241c88317",
"version" : "1.6.1"
}
}
],
"version" : 2
}

View File

@@ -0,0 +1,38 @@
// swift-tools-version: 5.9
import PackageDescription
let package = Package(
name: "Compound",
platforms: [.iOS(.v17)],
products: [
.library(name: "Compound", targets: ["Compound"])
],
dependencies: [
.package(url: "https://github.com/element-hq/compound-design-tokens", exact: "6.0.0"),
// .package(path: "../compound-design-tokens"),
.package(url: "https://github.com/siteline/SwiftUI-Introspect", from: "26.0.0"),
.package(url: "https://github.com/SFSafeSymbols/SFSafeSymbols", from: "6.2.0"),
.package(url: "https://github.com/pointfreeco/swift-snapshot-testing", exact: "1.18.3")
],
targets: [
.target(
name: "Compound",
dependencies: [
.product(name: "CompoundDesignTokens", package: "compound-design-tokens"),
.product(name: "SwiftUIIntrospect", package: "SwiftUI-Introspect"),
.product(name: "SFSafeSymbols", package: "SFSafeSymbols")
]
),
.testTarget(
name: "CompoundTests",
dependencies: [
"Compound",
.product(name: "SnapshotTesting", package: "swift-snapshot-testing")
],
exclude: [
"__Snapshots__"
]
)
]
)

View File

@@ -0,0 +1,312 @@
//
// Copyright 2022-2024 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 {
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 )
}
}
}

View File

@@ -0,0 +1,16 @@
//
// Copyright 2022-2024 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 Text {
/// Styles a text with the Compound design tokens to be displayed as a text field placeholder.
func compoundTextFieldPlaceholder() -> Text {
font(.compound.bodyLG)
.foregroundColor(.compound.textSecondary)
}
}

View File

@@ -0,0 +1,67 @@
//
// Copyright 2023, 2024 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 ToggleStyle where Self == CompoundToggleStyle {
/// A toggle style that applies Compound design tokens to display a Switch row within a `Form`.
static var compound: CompoundToggleStyle {
CompoundToggleStyle()
}
}
/// Default toggle styling for form rows.
///
/// The toggle is given the form row label style and is tinted correctly.
public struct CompoundToggleStyle: ToggleStyle {
public func makeBody(configuration: Configuration) -> some View {
Toggle(isOn: configuration.$isOn) {
configuration.label
.foregroundColor(.compound.textPrimary)
}
.tint(.compound.iconAccentTertiary)
}
}
public struct CompoundToggleStyle_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
VStack(spacing: 16) {
states
}
.padding(32)
}
@ViewBuilder
public static var states: some View {
VStack(spacing: 16) {
Toggle("Title", isOn: .constant(false))
.toggleStyle(.compound)
.labelsHidden()
Toggle("Title", isOn: .constant(true))
.toggleStyle(.compound)
.labelsHidden()
}
.padding(.bottom, 32)
VStack(spacing: 16) {
Toggle("Title", isOn: .constant(true))
.toggleStyle(.compound)
Toggle("Title", isOn: .constant(false))
.toggleStyle(.compound)
Toggle(isOn: .constant(true)) {
Label("Title", systemImage: "square.dashed")
}
.toggleStyle(.compound)
Toggle(isOn: .constant(false)) {
Label("Title", systemImage: "square.dashed")
}
.toggleStyle(.compound)
}
}
}

View File

@@ -0,0 +1,69 @@
//
// Copyright 2024 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
/// The button component for sending messages and media.
public struct SendButton: View {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.colorScheme) private var colorScheme
/// The action to perform when the user triggers the button.
public let action: () -> Void
private var iconColor: Color {
guard isEnabled else { return .compound.iconQuaternary }
return colorScheme == .light ? .compound.iconOnSolidPrimary : .compound.iconPrimary
}
private var gradient: Gradient { isEnabled ? .compound.action : .init(colors: [.clear]) }
/// Creates a send button that performs the provided action.
public init(action: @escaping () -> Void) {
self.action = action
}
public var body: some View {
Button(action: action) {
CompoundIcon(\.sendSolid, size: .medium, relativeTo: .compound.headingLG)
.foregroundStyle(iconColor)
.scaledPadding(6, relativeTo: .compound.headingLG)
.background { buttonShape }
.compositingGroup()
}
}
var buttonShape: some View {
Circle()
.fill(LinearGradient(gradient: gradient, startPoint: .top, endPoint: .bottom))
}
}
// MARK: - Previews
public struct SendButton_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
VStack(spacing: 0) {
states
.padding(20)
.background(.compound.bgCanvasDefault)
states
.padding(20)
.background(.compound.bgCanvasDefault)
.environment(\.colorScheme, .dark)
}
.cornerRadius(20)
}
public static var states: some View {
HStack(spacing: 30) {
SendButton { }
.disabled(true)
SendButton { }
}
}
}

View File

@@ -0,0 +1,115 @@
//
// Copyright 2023, 2024 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 CompoundDesignTokens
import SwiftUI
public extension Color {
/// The colours used by Element as defined in Compound Design Tokens.
static let compound = CompoundColors()
}
public extension ShapeStyle where Self == Color {
/// The colours used by Element as defined in Compound Design Tokens.
static var compound: CompoundColors { Self.compound }
}
/// The colours used by Element as defined in Compound Design Tokens.
/// This struct contains only the colour tokens in a more usable form.
@dynamicMemberLookup
public class CompoundColors {
/// The base colour tokens that form the palette of available colours.
///
/// Normally these shouldn't be necessary, however in practice we may need
/// access for temporary tokens while waiting for official ones to be formalised.
private static let coreTokens = CompoundCoreColorTokens.self
/// The main semantic tokens generated from the Style Dictionary.
private let tokens: CompoundColorTokens
/// Runtime overrides for the `tokens` property.
private var overrides = [KeyPath<CompoundColorTokens, Color>: Color]()
public subscript(dynamicMember keyPath: KeyPath<CompoundColorTokens, Color>) -> Color {
return overrides[keyPath] ?? tokens[keyPath: keyPath]
}
/// Customise the colour at the specified key path with the supplied colour.
/// Supplying `nil` as the colour will remove any existing customisation.
public func override(_ keyPath: KeyPath<CompoundColorTokens, Color>, with color: Color?) {
overrides[keyPath] = color
}
init() {
let tokens = CompoundColorTokens()
self.tokens = tokens
decorativeColors = [
.init(background: tokens.bgDecorative1, text: tokens.textDecorative1),
.init(background: tokens.bgDecorative2, text: tokens.textDecorative2),
.init(background: tokens.bgDecorative3, text: tokens.textDecorative3),
.init(background: tokens.bgDecorative4, text: tokens.textDecorative4),
.init(background: tokens.bgDecorative5, text: tokens.textDecorative5),
.init(background: tokens.bgDecorative6, text: tokens.textDecorative6),
]
}
// MARK: - Decorative Colors
// Used to determine the background and text colors of avatars, usernames etc.
let decorativeColors: [DecorativeColor]
public func decorativeColor(for contentID: String) -> DecorativeColor {
decorativeColors[contentID.hashCode]
}
// MARK: - Awaiting Semantic Tokens
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _borderTextFieldFocused = coreTokens.gray500
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgBubbleIncoming = Color(UIColor { $0.isLight ? UIColor(coreTokens.gray300) : UIColor(coreTokens.gray400) })
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgBubbleOutgoing = Color(UIColor { $0.isLight ? UIColor(coreTokens.gray400) : UIColor(coreTokens.gray500) })
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCodeBlock = coreTokens.gray100
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _borderInteractiveSecondaryAlpha = coreTokens.alphaGray600
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCriticalSubtleAlpha = coreTokens.alphaRed300
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgEmptyItemAlpha = coreTokens.alphaGray500
}
private extension UITraitCollection {
/// Whether or not the trait collection contains a `userInterfaceStyle` of `.light`.
var isLight: Bool { userInterfaceStyle == .light }
}
public struct DecorativeColor: Equatable {
public let background: Color
public let text: Color
}
private extension String {
/// Calculates a numeric hash same as Element Web
/// See original function here https://github.com/matrix-org/matrix-react-sdk/blob/321dd49db4fbe360fc2ff109ac117305c955b061/src/utils/FormattingUtils.js#L47
var hashCode: Int {
let characterCodeSum = self.reduce(0) { sum, character in
sum + Int(character.unicodeScalars.first?.value ?? 0)
}
return (characterCodeSum % Color.compound.decorativeColors.count)
}
}

View File

@@ -0,0 +1,37 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import CompoundDesignTokens
import SwiftUI
public extension Gradient {
/// The gradients used by Element as defined in Compound Design Tokens.
static let compound = CompoundGradients()
}
/// The gradients used by Element as defined in Compound Design Tokens.
/// This struct only contains the gradients assembled from the individual colour stops.
public struct CompoundGradients {
// We need to use computed properties here so that the gradients include the
// latest token overrides that have been applied since the struct was created.
public var action: Gradient { .init(colors: [.compound.gradientActionStop1,
.compound.gradientActionStop2,
.compound.gradientActionStop3,
.compound.gradientActionStop4]) }
public var subtle: Gradient { .init(colors: [.compound.gradientSubtleStop1,
.compound.gradientSubtleStop2,
.compound.gradientSubtleStop3,
.compound.gradientSubtleStop4,
.compound.gradientSubtleStop5,
.compound.gradientSubtleStop6]) }
public var info: Gradient { .init(colors: [.compound.gradientInfoStop1,
.compound.gradientInfoStop2,
.compound.gradientInfoStop3,
.compound.gradientInfoStop4,
.compound.gradientInfoStop5,
.compound.gradientInfoStop6]) }
}

View File

@@ -0,0 +1,53 @@
//
// Copyright 2023, 2024 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 CompoundDesignTokens
import UIKit
public extension UIColor {
/// The colours used by Element as defined in Compound Design Tokens.
static let compound = CompoundUIColors()
}
/// The colours used by Element as defined in Compound Design Tokens.
/// This struct contains only the colour tokens in a more usable form.
@dynamicMemberLookup
public class CompoundUIColors {
/// The base colour tokens that form the palette of available colours.
///
/// Normally these shouldn't be necessary, however in practice we may need
/// access for temporary tokens while waiting for official ones to be formalised.
private static let coreTokens = CompoundCoreUIColorTokens.self
/// The main semantic tokens generated from the Style Dictionary.
private let tokens = CompoundUIColorTokens()
/// Runtime overrides for the `tokens` property.
private var overrides = [KeyPath<CompoundUIColorTokens, UIColor>: UIColor]()
public subscript(dynamicMember keyPath: KeyPath<CompoundUIColorTokens, UIColor>) -> UIColor {
return overrides[keyPath] ?? tokens[keyPath: keyPath]
}
/// Customise the colour at the specified key path with the supplied colour.
/// Supplying `nil` as the colour will remove any existing customisation.
public func override(_ keyPath: KeyPath<CompoundUIColorTokens, UIColor>, with color: UIColor?) {
overrides[keyPath] = color
}
// MARK: - Awaiting Semantic Tokens
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgCodeBlock = coreTokens.gray100
/// This token is a placeholder and hasn't been finalised.
@available(iOS, deprecated: 100000.0, message: "This token should be generated by now.")
public let _bgSubtleSecondaryAlpha = coreTokens.alphaGray300
}
private extension UITraitCollection {
/// Whether or not the trait collection contains a `userInterfaceStyle` of `.light`.
var isLight: Bool { userInterfaceStyle == .light }
}

View File

@@ -0,0 +1,45 @@
//
// Copyright 2023, 2024 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
@_spi(Advanced) import SwiftUIIntrospect
public extension PlatformViewVersionPredicate<NavigationStackType, UINavigationController> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<WindowType, UIWindow> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<TextFieldType, UITextField> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<ScrollViewType, UIScrollView> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<ViewControllerType, UIViewController> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}
public extension PlatformViewVersionPredicate<TabViewType, UITabBarController> {
static var supportedVersions: Self {
.iOS(.v17...)
}
}

View File

@@ -0,0 +1,52 @@
//
// Copyright 2023, 2024 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 Font {
/// The fonts used by Element as defined in Compound Design Tokens.
static let compound = CompoundFonts()
}
/// A manual mapping of the Compound font styles to iOS styles. This will be
/// generated directly from the style dictionary in the future.
public struct CompoundFonts {
public let bodyXS = Font.caption
public let bodyXSSemibold = Font.caption.weight(.semibold)
public let bodySM = Font.footnote
public let bodySMSemibold = Font.footnote.weight(.semibold)
public let bodyMD = Font.subheadline
public let bodyMDSemibold = Font.subheadline.weight(.semibold)
public let bodyLG = Font.body
public let bodyLGSemibold = Font.body.weight(.semibold)
public let headingSM = Font.title3
public let headingSMSemibold = Font.title3.weight(.semibold)
public let headingMD = Font.title2
public let headingMDBold = Font.title2.bold()
public let headingLG = Font.title
public let headingLGBold = Font.title.bold()
public let headingXL = Font.largeTitle
public let headingXLBold = Font.largeTitle.bold()
}
public extension Font.TextStyle {
/// The text styles used by Element as defined in Compound Design Tokens.
static let compound = CompoundTextStyles()
}
/// A manual mapping of the Compound font styles to iOS text styles. This is useful
/// for `@ScaledMetric` along with modifiers such as `scaledPadding` etc.
public struct CompoundTextStyles {
public let bodyXS = Font.TextStyle.caption
public let bodySM = Font.TextStyle.footnote
public let bodyMD = Font.TextStyle.subheadline
public let bodyLG = Font.TextStyle.body
public let headingSM = Font.TextStyle.title3
public let headingMD = Font.TextStyle.title2
public let headingLG = Font.TextStyle.title
public let headingXL = Font.TextStyle.largeTitle
}

View File

@@ -0,0 +1,72 @@
import SwiftUI
/// The size of a SwiftUI font.
enum FontSize {
case custom(CGFloat, Font.TextStyle?)
case style(Font.TextStyle)
/// The raw value in points.
var value: CGFloat {
switch self {
case .custom(let size, _):
return size
case .style(let style):
switch style {
case .largeTitle:
return UIFont.preferredFont(forTextStyle: .largeTitle).pointSize
case .title:
return UIFont.preferredFont(forTextStyle: .title1).pointSize
case .title2:
return UIFont.preferredFont(forTextStyle: .title2).pointSize
case .title3:
return UIFont.preferredFont(forTextStyle: .title3).pointSize
case .body:
return UIFont.preferredFont(forTextStyle: .body).pointSize
case .headline:
return UIFont.preferredFont(forTextStyle: .headline).pointSize
case .callout:
return UIFont.preferredFont(forTextStyle: .callout).pointSize
case .subheadline:
return UIFont.preferredFont(forTextStyle: .subheadline).pointSize
case .footnote:
return UIFont.preferredFont(forTextStyle: .footnote).pointSize
case .caption:
return UIFont.preferredFont(forTextStyle: .caption1).pointSize
case .caption2:
return UIFont.preferredFont(forTextStyle: .caption2).pointSize
@unknown default:
return UIFont.preferredFont(forTextStyle: .body).pointSize
}
}
}
/// The text style of the font.
var style: Font.TextStyle {
switch self {
case .custom(_, let textStyle):
return textStyle ?? .body
case .style(let textStyle):
return textStyle
}
}
static func reflecting(_ font: Font) -> FontSize? {
let mirror = Mirror(reflecting: font)
guard let provider = mirror.descendant("provider", "base") else { return nil }
return resolveFontSize(provider)
}
private static func resolveFontSize(_ provider: Any) -> FontSize? {
let mirror = Mirror(reflecting: provider)
if let size = mirror.descendant("size") as? CGFloat {
return .custom(size, mirror.descendant("textStyle") as? Font.TextStyle)
} else if let textStyle = mirror.descendant("style") as? Font.TextStyle {
return .style(textStyle)
}
// recurse to handle modifiers.
guard let provider = mirror.descendant("base", "provider", "base") else { return nil }
return resolveFontSize(provider)
}
}

View File

@@ -0,0 +1,250 @@
//
// Copyright 2023, 2024 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.
//
@_exported import CompoundDesignTokens
import SwiftUI
public extension Image {
/// The icons used by Element as defined in Compound Design Tokens.
static let compound = CompoundIcons()
}
/// A view that displays an icon from Compound. The icon defaults to a size of 24pt
/// and scales with Dynamic Type, relative to any font given to it by the `font` modifier.
public struct CompoundIcon: View {
/// The size of the icon.
public enum Size {
/// An icon size of 16pt.
case xSmall
/// An icon size of 20pt.
case small
/// An icon size of 24pt.
case medium
/// A custom icon size.
case custom(CGFloat)
var value: CGFloat {
switch self {
case .xSmall: return 16
case .small: return 20
case .medium: return 24
case .custom(let size): return size
}
}
}
private var image: Image
private var size: Size
private var font: Font
private var fontSize: FontSize {
FontSize.reflecting(font) ?? .style(.body)
}
/// Creates an icon using a key path from the Compound tokens. The size will be
/// 24pt and will scale relative to the `bodyLG` font when Dynamic Type is used.
///
/// - Parameters:
/// - icon: The icon to show.
public init(_ icon: KeyPath<CompoundIcons, Image>) {
image = .compound[keyPath: icon]
self.size = .medium
self.font = .compound.bodyLG
}
/// Creates an icon using a key path from the Compound tokens.
///
/// - Parameters:
/// - icon: The icon to show.
/// - size: The size of the icon.
/// - font: The font that should be used for scaling with Dynamic Type.
public init(_ icon: KeyPath<CompoundIcons, Image>, size: Size, relativeTo font: Font) {
image = .compound[keyPath: icon]
self.size = size
self.font = font
}
/// Creates an icon using a custom image to allow assets from outside
/// of Compound to scale in the same way as icons. The size will be 24pt
/// and will scale relative to the `bodyLG` font when Dynamic Type is used.
///
/// - Parameters:
/// - customImage: The image that should be displayed
///
/// ** Note:** The image should have a square frame or it may end up distorted.
public init(customImage: Image) {
image = customImage
self.size = .medium
self.font = .compound.bodyLG
}
/// Creates an icon using a custom image to allow assets from outside
/// of Compound to scale in the same way as icons.
///
/// - Parameters:
/// - customImage: The image that should be displayed
/// - size: The size of the icon.
/// - font: The font that should be used for scaling with Dynamic Type.
///
/// ** Note:** The image should have a square frame or it may end up distorted.
public init(customImage: Image, size: Size, relativeTo font: Font) {
image = customImage
self.size = size
self.font = font
}
public var body: some View {
image
.resizable()
.modifier(CompoundIconFrame(fontSize: size.value, textStyle: fontSize.style))
}
}
/// A simple modifier that applies a square frame of a given size that will be
/// scaled dynamically based upon the specified text style.
private struct CompoundIconFrame: ViewModifier {
@ScaledMetric private var size: CGFloat
init(fontSize: CGFloat, textStyle: Font.TextStyle) {
_size = ScaledMetric(wrappedValue: fontSize, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content
.frame(width: size, height: size)
}
}
public extension Label {
/// Creates a label with an icon from Compound and a title generated from a string.
/// The icon size will be 24pt, scaling relative to the `bodyLG` with Dynamic Type.
/// - Parameters:
/// - title: A string used as the labels title.
/// - icon: The icon to use from Compound.
init(_ title: some StringProtocol, icon: KeyPath<CompoundIcons, Image>) where Title == Text, Icon == CompoundIcon {
self.init {
Text(title)
} icon: {
CompoundIcon(icon)
}
}
/// Creates a label with an icon from Compound and a title generated from a string.
/// - Parameters:
/// - title: A string used as the labels title.
/// - icon: The icon to use from Compound.
/// - iconSize: The size of the icon.
/// - font: The font that the icon should scale relative to with Dynamic Type.
init(_ title: some StringProtocol,
icon: KeyPath<CompoundIcons, Image>,
iconSize: CompoundIcon.Size,
relativeTo font: Font) where Title == Text, Icon == CompoundIcon {
self.init {
Text(title)
} icon: {
CompoundIcon(icon, size: iconSize, relativeTo: font)
}
}
}
// MARK: - Previews
struct CompoundIcon_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
form
.previewDisplayName("Form")
buttons
.padding(8)
.previewLayout(.sizeThatFits)
.previewDisplayName("Buttons")
accessibilityIcons
.previewDisplayName("Accessibility Icons Only")
accessibilityLabels
.previewDisplayName("Accessibility Labels")
}
static var accessibilityIcons: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
HStack {
CompoundIcon(\.userProfile, size: .xSmall, relativeTo: .compound.bodyXS)
CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodySM)
CompoundIcon(\.userProfile, size: .medium, relativeTo: .compound.bodyLG)
}
.dynamicTypeSize(size)
}
}
}
static var accessibilityLabels: some View {
Grid(alignment: .leading) {
ForEach(DynamicTypeSize.allCases, id: \.self) {
size in
GridRow {
Label("Test XS", icon: \.userProfile, iconSize: .xSmall, relativeTo: .compound.bodyXS)
.font(.compound.bodyXS)
Label("Test Small", icon: \.userProfile, iconSize: .small, relativeTo: .compound.bodySM)
.font(.compound.bodySM)
Label("Test Medium", icon: \.userProfile, iconSize: .medium, relativeTo: .compound.bodyLG)
.font(.compound.bodyLG)
}
.lineLimit(1)
.dynamicTypeSize(size)
}
}
}
static var form: some View {
Form {
Section {
ListRow(label: .action(title: "Plain Icon", icon: \.userProfile),
kind: .label)
ListRow(label: .default(title: "Plain Icon", icon: \.userProfile),
kind: .label)
ListRow(label: .default(title: "Plain Icon", systemIcon: .personCropCircle),
kind: .label)
}
}
.compoundList()
.safeAreaInset(edge: .bottom) {
Button { } label: {
Label("Button", icon: \.userProfile)
}
.buttonStyle(.compound(.primary))
.padding()
}
}
static var buttons: some View {
VStack {
Button { } label: {
Label { Text("Body Large") } icon: {
CompoundIcon(\.userProfile, size: .medium, relativeTo: .compound.bodyLG)
}
}
.font(.compound.bodyLG)
.buttonStyle(.borderedProminent)
Button { } label: {
Label { Text("Body Small") } icon: {
CompoundIcon(\.userProfile, size: .small, relativeTo: .compound.bodySM)
}
}
.font(.compound.bodySM)
.buttonStyle(.borderedProminent)
Button { } label: {
Label { Text("Body xSmall") } icon: {
CompoundIcon(\.userProfile, size: .xSmall, relativeTo: .compound.bodyXS)
}
}
.font(.compound.bodyXS)
.buttonStyle(.borderedProminent)
}
}
}

View File

@@ -0,0 +1,56 @@
//
// Copyright 2023, 2024 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 View {
/// Positions this view within an invisible frame with a square size that scales relative to the user's selected font size.
func scaledFrame(size: CGFloat, alignment: Alignment = .center, relativeTo textStyle: Font.TextStyle = .body) -> some View {
scaledFrame(width: size, height: size, alignment: alignment, relativeTo: textStyle)
}
/// Positions this view within an invisible frame with a size that scales relative to the user's selected font size.
func scaledFrame(width: CGFloat, height: CGFloat, alignment: Alignment = .center, relativeTo textStyle: Font.TextStyle = .body) -> some View {
modifier(ScaledFrameModifier(width: width, height: height, alignment: alignment, relativeTo: textStyle))
}
}
private struct ScaledFrameModifier: ViewModifier {
@ScaledMetric var width: CGFloat
@ScaledMetric var height: CGFloat
let alignment: Alignment
init(width: CGFloat, height: CGFloat, alignment: Alignment, relativeTo textStyle: Font.TextStyle) {
_width = ScaledMetric(wrappedValue: width, relativeTo: textStyle)
_height = ScaledMetric(wrappedValue: height, relativeTo: textStyle)
self.alignment = alignment
}
func body(content: Content) -> some View {
content.frame(width: width, height: height, alignment: alignment)
}
}
// MARK: - Previews
struct ScaledFrameModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
likeButtonLabel
.dynamicTypeSize(size)
}
}
}
static var likeButtonLabel: some View {
Image(systemSymbol: .heartCircleFill)
.resizable()
.foregroundStyle(.compound.iconAccentTertiary)
.scaledFrame(size: 24, relativeTo: .title)
}
}

View File

@@ -0,0 +1,53 @@
//
// Copyright 2024 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 View {
/// Offset this view by the specified horizontal and vertical distances, scaled relative to the user's selected font size.
func scaledOffset(x: CGFloat = 0, y: CGFloat = 0, relativeTo textStyle: Font.TextStyle = .body) -> some View {
modifier(ScaledOffsetModifier(x: x, y: y, relativeTo: textStyle))
}
}
private struct ScaledOffsetModifier: ViewModifier {
@ScaledMetric var x: CGFloat
@ScaledMetric var y: CGFloat
init(x: CGFloat, y: CGFloat, relativeTo textStyle: Font.TextStyle) {
_x = ScaledMetric(wrappedValue: x, relativeTo: textStyle)
_y = ScaledMetric(wrappedValue: y, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.offset(x: x, y: y)
}
}
// MARK: - Previews
struct ScaledOffsetModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
verifiedUserComposite
.dynamicTypeSize(size)
}
}
}
static var verifiedUserComposite: some View {
CompoundIcon(\.userSolid)
.foregroundStyle(.compound.iconAccentTertiary)
.overlay {
CompoundIcon(\.verified, size: .custom(10), relativeTo: .body)
.scaledOffset(x: 6, y: 6)
.foregroundStyle(.compound.gradientActionStop1)
}
}
}

View File

@@ -0,0 +1,54 @@
//
// Copyright 2023, 2024 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 View {
/// Adds an equal padding amount to all edges of this view, scaled relative to the user's selected font size.
func scaledPadding(_ length: CGFloat, relativeTo textStyle: Font.TextStyle = .body) -> some View {
scaledPadding(.all, length, relativeTo: textStyle)
}
/// Adds an equal padding amount to specific edges of this view, scaled relative to the user's selected font size.
func scaledPadding(_ edges: Edge.Set, _ length: CGFloat, relativeTo textStyle: Font.TextStyle = .body) -> some View {
modifier(ScaledPaddingModifier(edges: edges, length: length, textStyle: textStyle))
}
}
private struct ScaledPaddingModifier: ViewModifier {
let edges: Edge.Set
@ScaledMetric var length: CGFloat
init(edges: Edge.Set, length: CGFloat, textStyle: Font.TextStyle) {
self.edges = edges
_length = ScaledMetric(wrappedValue: length, relativeTo: textStyle)
}
func body(content: Content) -> some View {
content.padding(edges, length)
}
}
// MARK: - Previews
struct ScaledPaddingModifier_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
VStack {
ForEach(DynamicTypeSize.allCases, id: \.self) { size in
userProfileButtonLabel
.dynamicTypeSize(size)
}
}
}
static var userProfileButtonLabel: some View {
CompoundIcon(\.userProfile, size: .medium, relativeTo: .title)
.foregroundStyle(.compound.iconOnSolidPrimary)
.scaledPadding(6, relativeTo: .title)
.background(.compound.bgAccentRest, in: Circle())
}
}

View File

@@ -0,0 +1,70 @@
//
// Copyright 2023, 2024 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
struct ListInlinePicker<SelectedValue: Hashable>: View {
let title: String?
@Binding var selection: SelectedValue
let items: [(title: String, tag: SelectedValue)]
let isWaiting: Bool
var body: some View {
ForEach(items, id: \.tag) { item in
ListRow(label: .plain(title: item.title),
details: isWaiting ? .isWaiting(selection == item.tag) : nil,
kind: .selection(isSelected: !isWaiting ? selection == item.tag : false) {
var transaction = Transaction()
transaction.disablesAnimations = true
withTransaction(transaction) {
selection = item.tag
}
})
}
}
}
// MARK: - Previews
struct ListInlinePicker_Previews: PreviewProvider, TestablePreview {
static var previews: some View { Preview() }
struct Preview: View {
@State var selection = "Item 1"
let items = ["Item 1", "Item 2", "Item 3"]
var body: some View {
Form {
Section("Compound") {
ListInlinePicker(title: "Title",
selection: $selection,
items: items.map { (title: $0, tag: $0) },
isWaiting: false)
}
Section("Compound with loader") {
ListInlinePicker(title: "Title",
selection: $selection,
items: items.map { (title: $0, tag: $0) },
isWaiting: true)
}
Section("Native") {
Picker("", selection: $selection) {
ForEach(items, id: \.self) { item in
Text(item)
.tag(item)
}
}
.pickerStyle(.inline)
.labelsHidden()
}
}
}
}
}

View File

@@ -0,0 +1,476 @@
//
// Copyright 2023, 2024 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 enum ListRowPadding {
public static let horizontal: CGFloat = 16
public static let vertical: CGFloat = 13
public static let insets = EdgeInsets(top: vertical,
leading: horizontal,
bottom: vertical,
trailing: horizontal)
public static let textFieldInsets = EdgeInsets(top: 11,
leading: horizontal,
bottom: 11,
trailing: horizontal)
}
public struct ListRow<Icon: View, DetailsIcon: View, CustomContent: View, SelectionValue: Hashable>: View {
@Environment(\.isEnabled) private var isEnabled
let label: ListRowLabel<Icon>
let details: ListRowDetails<DetailsIcon>?
public enum Kind<CustomContent: View, SelectionValue: Hashable> {
case label
case button(action: () -> Void)
case navigationLink(action: () -> Void)
case picker(selection: Binding<SelectionValue>, items: [(title: String, tag: SelectionValue)])
case toggle(Binding<Bool>)
case inlinePicker(selection: Binding<SelectionValue>, items: [(title: String, tag: SelectionValue)])
case selection(isSelected: Bool, action: () -> Void)
case multiSelection(isSelected: Bool, action: () -> Void)
case textField(text: Binding<String>, axis: Axis?)
case secureField(text: Binding<String>)
case custom(() -> CustomContent)
public static func textField(text: Binding<String>) -> Self {
.textField(text: text, axis: nil)
}
}
let kind: Kind<CustomContent, SelectionValue>
public var body: some View {
rowContent
.buttonStyle(ListRowButtonStyle())
.listRowInsets(EdgeInsets())
.listRowBackground(Color.compound.bgCanvasDefaultLevel1)
.listRowSeparatorTint(.compound._borderInteractiveSecondaryAlpha)
}
@ViewBuilder
var rowContent: some View {
switch kind {
case .label:
RowContent(details: details) { label }
case .button(let action):
Button(action: action) {
RowContent(details: details) { label }
}
case .navigationLink(let action):
Button(action: action) {
RowContent(details: details, accessory: .navigationLink) { label }
}
case .picker(let selection, let items):
HStack(spacing: 0) {
label
Spacer()
// Note: VoiceOver label already provided.
Picker("", selection: selection) {
ForEach(items, id: \.tag) { item in
Text(item.title)
.tag(item.tag)
}
}
.labelsHidden()
.padding(.vertical, -10)
.padding(.trailing, ListRowPadding.horizontal)
}
.accessibilityElement(children: .combine)
case .toggle(let binding):
HStack(spacing: 0) {
label
Spacer()
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
if let details {
ListRowTrailingSection(details)
}
// Note: VoiceOver label already provided.
Toggle("", isOn: binding)
.toggleStyle(.compound)
.labelsHidden()
.padding(.vertical, -10)
}
}
.padding(.trailing, ListRowPadding.horizontal)
.accessibilityElement(children: .combine)
case .inlinePicker(let selection, let items):
ListInlinePicker(title: label.title ?? "",
selection: selection,
items: items,
isWaiting: details?.isWaiting ?? false)
case .selection(let isSelected, let action):
Button(action: action) {
RowContent(details: details, accessory: .selection(isSelected)) { label }
}
.isToggle()
case .multiSelection(let isSelected, let action):
Button(action: action) {
RowContent(details: details, accessory: .multiSelection(isSelected)) { label }
}
.isToggle()
case .textField(let text, let axis):
TextField(text: text, axis: axis) {
Text(label.title ?? "")
.compoundTextFieldPlaceholder()
}
.tint(.compound.iconAccentTertiary)
.foregroundStyle(isEnabled ? .compound.textPrimary : .compound.textDisabled)
.listRowInsets(ListRowPadding.textFieldInsets)
case .secureField(let text):
SecureField(text: text) {
Text(label.title ?? "")
.compoundTextFieldPlaceholder()
}
.tint(.compound.iconAccentTertiary)
.foregroundStyle(isEnabled ? .compound.textPrimary : .compound.textDisabled)
.listRowInsets(ListRowPadding.textFieldInsets)
case .custom(let content):
content()
}
}
}
// MARK: - Initialisers
// Normal row with a details icon
public extension ListRow where CustomContent == EmptyView {
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) {
self.label = label
self.details = details
self.kind = kind
}
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
self.label = label
self.details = details
self.kind = kind
}
}
// Normal row without a details icon.
public extension ListRow where DetailsIcon == EmptyView, CustomContent == EmptyView {
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) {
self.label = label
self.details = details
self.kind = kind
}
init(label: ListRowLabel<Icon>,
details: ListRowDetails<DetailsIcon>? = nil,
kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
self.label = label
self.details = details
self.kind = kind
}
}
// Custom row without a label or details label.
public extension ListRow where Icon == EmptyView, DetailsIcon == EmptyView {
init(kind: Kind<CustomContent, SelectionValue>) {
self.label = ListRowLabel()
self.details = nil
self.kind = kind
}
init(kind: Kind<CustomContent, SelectionValue>) where SelectionValue == String {
self.label = ListRowLabel()
self.details = nil
self.kind = kind
}
}
/// The standard content for labels, and button based rows.
///
/// This doesn't use `LabeledContent` as that will happily build using an `EmptyView`
/// in the content. This creates an issue where the label ends up hidden to VoiceOver,
/// presumably because SwiftUI thinks that the row doesn't contain any content.
private struct RowContent<Label: View, DetailsIcon: View>: View {
let details: ListRowDetails<DetailsIcon>?
var accessory: ListRowAccessory?
let label: () -> Label
var body: some View {
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
label()
.frame(maxWidth: .infinity)
if details != nil || accessory != nil {
ListRowTrailingSection(details, accessory: accessory)
}
}
.frame(maxHeight: .infinity)
.padding(.trailing, ListRowPadding.horizontal)
.accessibilityElement(children: .combine)
}
}
// MARK: - Helpers
private extension TextField {
/// Creates a text field with an optional preferred axis. Hard coding a default resulted
/// in the underlying component always being a `UITextView` during introspection.
/// This initialiser does the right thing when not supplying an axis in `ListRow.Kind`.
init(text: Binding<String>, axis: Axis?, label: () -> Label) {
if let axis {
self.init(text: text, axis: axis, label: label)
} else {
self.init(text: text, label: label)
}
}
}
private extension Button {
/// Adds the `isToggle` accessibility trait on iOS 17+
@ViewBuilder func isToggle() -> some View {
if #available(iOS 17.0, *) {
accessibilityAddTraits(.isToggle)
} else {
self
}
}
}
// MARK: - Previews
public struct ListRow_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
Form {
Section {
labels
buttons
pickers
toggles
selection
actionButtons
plainButton
}
centeredActionButtonSections
descriptionLabelSection
avatarSection
othersSection
}
.compoundList()
.frame(idealHeight: 2100) // Snapshot height
.previewLayout(.sizeThatFits)
}
static var labels: some View {
ListRow(label: .default(title: "Label",
description: "Non-interactive item",
systemIcon: .squareDashed),
details: .label(title: "Content",
systemIcon: .squareDashed,
isWaiting: true),
kind: .label)
}
@ViewBuilder static var buttons: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
ListRow(label: .default(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
ListRow(label: .default(title: "Destructive",
systemIcon: .squareDashed,
role: .destructive),
kind: .button { print("I will delete things!") })
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
details: .label(title: "Details", systemIcon: .squareDashed),
kind: .navigationLink { print("Perform navigation!") })
ListRow(label: .default(title: "Title",
systemIcon: .squareDashed),
kind: .navigationLink { print("Perform navigation!") })
}
@ViewBuilder static var pickers: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
kind: .picker(selection: .constant(0),
items: [(title: "Item 1", tag: 0),
(title: "Item 2", tag: 1),
(title: "Item 3", tag: 2)]))
ListRow(label: .default(title: "Very very very very very very long title",
description: "Description…",
systemIcon: .squareDashed),
kind: .picker(selection: .constant(0),
items: [(title: "Item 1", tag: 0),
(title: "Item 2", tag: 1),
(title: "Item 3", tag: 2)]))
ListRow(label: .default(title: "Title", systemIcon: .squareDashed),
kind: .picker(selection: .constant("Item 1"),
items: [(title: "Item 1", tag: "Item 1"),
(title: "Item 2", tag: "Item 2"),
(title: "Item 3", tag: "Item 3")]))
}
@ViewBuilder static var toggles: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
kind: .toggle(.constant(true)))
ListRow(label: .default(title: "Very very very very very very very long title",
description: "Description…",
systemIcon: .squareDashed),
kind: .toggle(.constant(true)))
ListRow(label: .default(title: "Title", systemIcon: .squareDashed),
kind: .toggle(.constant(true)))
ListRow(label: .default(title: "Title", systemIcon: .squareDashed),
details: .isWaiting(true),
kind: .toggle(.constant(false)))
}
@ViewBuilder static var selection: some View {
ListRow(label: .default(title: "Title",
description: "Description…",
systemIcon: .squareDashed),
details: .title("Content"),
kind: .selection(isSelected: true) {
print("I was tapped!")
})
ListRow(label: .default(title: "Title",
systemIcon: .squareDashed),
details: .title("Content"),
kind: .selection(isSelected: true) {
print("I was tapped!")
})
ListRow(label: .plain(title: "Title"),
kind: .inlinePicker(selection: .constant("Item 1"),
items: [(title: "Item 1", tag: "Item 1"),
(title: "Item 2", tag: "Item 2"),
(title: "Item 3", tag: "Item 3")]))
}
@ViewBuilder static var actionButtons: some View {
ListRow(label: .action(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
ListRow(label: .action(title: "Title",
systemIcon: .squareDashed,
role: .destructive),
kind: .button { print("I was tapped!") })
ListRow(label: .action(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
.disabled(true)
}
static var plainButton: some View {
ListRow(label: .plain(title: "Title"),
kind: .button { print("I was tapped!") })
}
@ViewBuilder static var centeredActionButtonSections: some View {
Section {
ListRow(label: .centeredAction(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
}
Section {
ListRow(label: .centeredAction(title: "Title",
systemIcon: .squareDashed,
role: .destructive),
kind: .button { print("I was tapped!") })
}
Section {
ListRow(label: .centeredAction(title: "Title",
systemIcon: .squareDashed),
kind: .button { print("I was tapped!") })
.disabled(true)
}
}
static var descriptionLabelSection: some View {
Section {
ListRow(label: .description("This is a row in the list, with a multiline description but it doesn't have either an icon or a title, just this text here."),
kind: .label)
}
}
static var avatarSection: some View {
Section {
ListRow(label: .avatar(title: "Alice",
description: "@alice:element.io",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background)),
kind: .multiSelection(isSelected: true) { })
ListRow(label: .avatar(title: "Bob",
description: "@bob:element.io",
icon: Circle().foregroundStyle(.compound.decorativeColors[1].background)),
kind: .multiSelection(isSelected: false) { })
ListRow(label: .avatar(title: "Dan",
status: "Pending",
description: "@dan:element.io",
icon: Circle().foregroundStyle(.compound.decorativeColors[3].background)),
kind: .multiSelection(isSelected: false) { })
.disabled(true)
ListRow(label: .avatar(title: "@charlie:fake.com",
description: "This user can't be found, so the invite may not be received.",
icon: Circle().foregroundStyle(.compound.decorativeColors[2].background),
role: .error),
kind: .button { })
}
}
@ViewBuilder static var othersSection: some View {
Section {
ListRow(kind: .custom {
Text("This is a custom row")
.padding(.horizontal, 16)
.padding(.vertical, 20)
})
ListRow(label: .plain(title: "Placeholder"),
kind: .textField(text: .constant("This is a disabled text field")))
.disabled(true)
ListRow(label: .plain(title: "Placeholder"),
kind: .textField(text: .constant(""), axis: .vertical))
.lineLimit(4...)
ListRow(label: .plain(title: "Password"),
kind: .secureField(text: .constant("p4ssw0rd")))
}
}
}
struct ListRowLoadingSelection_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
ListRow(label: .plain(title: "Selected",
description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."),
details: .isWaiting(false),
kind: .selection(isSelected: true) { })
ListRow(label: .plain(title: "Unselected",
description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."),
details: .isWaiting(false),
kind: .selection(isSelected: false) { })
ListRow(label: .plain(title: "Unselected & Loading",
description: "This is a long multiline description which shows what happens when wrapping with a details view and selection, specifically, an activity indicator in the details."),
details: .isWaiting(true),
kind: .selection(isSelected: false) { })
}
.compoundList()
}
}

View File

@@ -0,0 +1,112 @@
//
// Copyright 2023, 2024 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
/// A view to be added on the trailing edge of a form row.
public struct ListRowAccessory: View {
@Environment(\.isEnabled) private var isEnabled
enum Kind {
/// A navigation chevron.
case navigationLink
/// A checkmark.
case selected
/// ``selected`` but invisible to reserve space.
case unselected
/// A circular checkmark.
case multiSelected
/// An empty circle.
case multiUnselected
}
/// A chevron to indicate that the button pushes another screen.
public static var navigationLink: Self {
Self.init(kind: .navigationLink)
}
/// A checkmark (or reserved space) to indicate that the row is selected.
public static func selection(_ isSelected: Bool) -> Self {
Self.init(kind: isSelected ? .selected : .unselected)
}
/// A circular checkmark (or empty circle) to indicate that the row is one of multiple selected.
public static func multiSelection(_ isSelected: Bool) -> Self {
Self.init(kind: isSelected ? .multiSelected : .multiUnselected)
}
let kind: Kind
/// Negative padding added to prevent the accessory interfering with the row's padding.
private var verticalPaddingFix: CGFloat { -4 }
/// Absolute bodge until we have the circle icon in Compound.
@ScaledMetric private var circleOverlayInsets = 5
public var body: some View {
switch kind {
case .navigationLink:
CompoundIcon(\.chevronRight)
.foregroundColor(.compound.iconTertiaryAlpha)
.flipsForRightToLeftLayoutDirection(true)
case .selected:
CompoundIcon(\.check)
.foregroundColor(isEnabled ? .compound.iconAccentPrimary : .compound.iconDisabled)
.accessibilityAddTraits(.isSelected)
.padding(.vertical, verticalPaddingFix)
case .unselected:
CompoundIcon(\.check)
.hidden()
.padding(.vertical, verticalPaddingFix)
case .multiSelected:
CompoundIcon(\.checkCircleSolid)
.foregroundColor(isEnabled ? .compound.iconSuccessPrimary : .compound.iconDisabled)
.accessibilityAddTraits(.isSelected)
.padding(.vertical, verticalPaddingFix)
case .multiUnselected:
CompoundIcon(\.circle)
.foregroundColor(isEnabled ? .compound.borderInteractivePrimary : .compound.borderDisabled)
.padding(.vertical, verticalPaddingFix)
}
}
}
// MARK: - Previews
struct ListRowAccessory_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Grid(alignment: .leading, verticalSpacing: 16) {
row(title: "Navigation Link", accessory: .navigationLink)
row(title: "Navigation Link disabled", accessory: .navigationLink)
.disabled(true)
row(title: "Selected", accessory: .selection(true))
row(title: "Selected disabled", accessory: .selection(true))
.disabled(true)
row(title: "Unselected", accessory: .selection(false))
row(title: "Unselected disabled", accessory: .selection(false))
.disabled(true)
row(title: "Multi-selected", accessory: .multiSelection(true))
row(title: "Multi-selected disabled", accessory: .multiSelection(true))
.disabled(true)
row(title: "Multi-unselected", accessory: .multiSelection(false))
row(title: "Multi-unselected disabled", accessory: .multiSelection(false))
.disabled(true)
}
.previewDisplayName("Accessories")
}
static func row(title: String, accessory: ListRowAccessory) -> some View {
GridRow {
accessory
Text(title)
.foregroundStyle(.compound.textSecondary)
}
}
}

View File

@@ -0,0 +1,65 @@
//
// Copyright 2023, 2024 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
// TODO: Check if the primitive style is actually needed now the insets are part of ListRow.
// It might still be useful for ListRow(kind: .custom) usage?
/// Default button styling for list rows.
///
/// The primitive style is needed to set the list row insets to `0`. The inner style is then needed
/// to change the background colour depending on whether the button is currently pressed or not.
public struct ListRowButtonStyle: PrimitiveButtonStyle {
public func makeBody(configuration: Configuration) -> some View {
Button(role: configuration.role, action: configuration.trigger) {
configuration.label
}
.buttonStyle(Style())
}
/// Inner style used to set the pressed background colour.
struct Style: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.contentShape(Rectangle())
.background(configuration.isPressed ? Color.compound.bgSubtlePrimary : .compound.bgCanvasDefaultLevel1)
}
}
}
// MARK: - Previews
// TODO: Fix the previews, either the style should expand the label to fill or
// the previews need to do this manually for demonstration purposes.
public struct ListRowButtonStyle_Previews: PreviewProvider, TestablePreview {
public static var previews: some View {
Form {
Section {
Button("Title") { }
.buttonStyle(ListRowButtonStyle.Style())
}
.listRowInsets(EdgeInsets())
Section {
Button("Title") { }
Button("Title") { }
Button("Title") { }
}
.buttonStyle(ListRowButtonStyle())
.listRowInsets(EdgeInsets())
Section {
ShareLink(item: "test")
.buttonStyle(ListRowButtonStyle())
}
.listRowInsets(EdgeInsets())
}
.compoundList()
}
}

View File

@@ -0,0 +1,94 @@
//
// Copyright 2023, 2024 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 CompoundDesignTokens
import SFSafeSymbols
import SwiftUI
/// The configuration of the details portion of a list row's trailing section.
/// This consists of the title, icon and a waiting indicator.
public struct ListRowDetails<Icon: View> {
var title: String?
var icon: Icon?
var counter: Int?
var isWaiting = false
// MARK: - Initialisers
public static func label(title: String,
icon: Icon,
counter: Int? = nil,
isWaiting: Bool = false) -> Self {
ListRowDetails(title: title,
icon: icon,
counter: counter,
isWaiting: isWaiting)
}
public static func label(title: String,
icon: KeyPath<CompoundIcons, Image>,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == CompoundIcon {
ListRowDetails(title: title,
icon: CompoundIcon(icon),
counter: counter,
isWaiting: isWaiting)
}
public static func label(title: String,
systemIcon: SFSymbol,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == Image {
ListRowDetails(title: title,
icon: Image(systemSymbol: systemIcon),
counter: counter,
isWaiting: isWaiting)
}
public static func icon(_ icon: Icon,
counter: Int? = nil,
isWaiting: Bool = false) -> Self {
ListRowDetails(icon: icon,
counter: counter,
isWaiting: isWaiting)
}
public static func icon(_ icon: KeyPath<CompoundIcons, Image>,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == CompoundIcon {
ListRowDetails(icon:CompoundIcon(icon),
counter: counter,
isWaiting: isWaiting)
}
public static func systemIcon(_ systemIcon: SFSymbol,
counter: Int? = nil,
isWaiting: Bool = false) -> Self where Icon == Image {
ListRowDetails(icon: Image(systemSymbol: systemIcon),
counter: counter,
isWaiting: isWaiting)
}
}
public extension ListRowDetails where Icon == Image {
static func title(_ title: String,
counter: Int? = nil,
isWaiting: Bool = false) -> Self {
ListRowDetails(title: title,
counter: counter,
isWaiting: isWaiting)
}
static func counter(_ counter: Int, isWaiting: Bool = false) -> Self {
ListRowDetails(counter: counter, isWaiting: isWaiting)
}
static func isWaiting(_ isWaiting: Bool) -> Self {
ListRowDetails(isWaiting: isWaiting)
}
}

View File

@@ -0,0 +1,373 @@
//
// Copyright 2023, 2024 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 CompoundDesignTokens
import SFSafeSymbols
import SwiftUI
/// The main label style used in the leading section of `ListRow`.
struct ListRowLabelStyle: LabelStyle {
let iconAlignment: VerticalAlignment
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: iconAlignment, spacing: 16) {
configuration.icon
configuration.title
}
}
}
/// The label style used in `ListRow` for centred buttons.
struct ListRowCenteredLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .center, spacing: 8) {
configuration.icon
configuration.title
}
}
}
/// The label style used in the leading section of `ListRow` that show an avatar as the icon.
///
/// Unlike the other styles, this one sizes the avatar internally.
struct ListRowAvatarLabelStyle: LabelStyle {
@ScaledMetric private var avatarSize = 32.0
func makeBody(configuration: Configuration) -> some View {
HStack(alignment: .center, spacing: 16) {
configuration.icon
.frame(width: avatarSize, height: avatarSize)
.padding(.vertical, -5) // Don't allow the avatar to size the row.
configuration.title
}
}
}
public struct ListRowLabel<Icon: View>: View {
@Environment(\.isEnabled) private var isEnabled
@Environment(\.lineLimit) private var lineLimit
@ScaledMetric private var iconSize = 30.0
var title: String?
var status: String?
var description: String?
var icon: Icon?
var role: Role?
public enum Role {
/// A role that indicates a destructive action.
case destructive
/// A role that indicates an error.
///
/// The label should contain a description when using this role.
case error
}
var iconAlignment: VerticalAlignment = .center
var hideIconBackground: Bool = false
enum Layout { case `default`, centered, avatar }
var layout: Layout = .default
var titleColor: Color {
guard isEnabled else { return .compound.textDisabled }
return role == .destructive ? .compound.textCriticalPrimary : .compound.textPrimary
}
var titleLineLimit: Int? { layout == .avatar ? 1 : lineLimit }
var statusColor: Color {
isEnabled ? .compound.textSecondary : .compound.textDisabled
}
var descriptionColor: Color {
isEnabled ? .compound.textSecondary : .compound.textDisabled
}
var descriptionLineLimit: Int? {
guard layout == .avatar else { return lineLimit }
return role != .error ? 1 : lineLimit
}
var iconForegroundColor: Color {
guard isEnabled else { return .compound.iconTertiaryAlpha }
if role == .destructive { return .compound.textCriticalPrimary }
return hideIconBackground ? .compound.iconPrimary : .compound.iconTertiaryAlpha
}
var iconBackgroundColor: Color {
if hideIconBackground { return .clear }
guard isEnabled else { return .compound._bgSubtleSecondaryAlpha }
return role == .destructive ? .compound._bgCriticalSubtleAlpha : .compound._bgSubtleSecondaryAlpha
}
public var body: some View {
Group {
switch layout {
case .default:
defaultBody
case .centered:
centeredBody
case .avatar:
avatarBody
}
}
.padding(.leading, ListRowPadding.horizontal)
.padding(.vertical, ListRowPadding.vertical)
}
var defaultBody: some View {
Label {
titleAndDescription
} icon: {
icon
.foregroundColor(iconForegroundColor)
.frame(width: iconSize, height: iconSize)
.background(iconBackgroundColor)
.clipShape(RoundedRectangle(cornerRadius: 8))
.padding(.vertical, -4) // Don't allow the background to size the row.
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.labelStyle(ListRowLabelStyle(iconAlignment: iconAlignment))
}
var centeredBody: some View {
Label {
if let title {
Text(title)
.font(.compound.bodyLG)
.foregroundColor(titleColor)
}
} icon: {
icon
.foregroundColor(iconForegroundColor)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.labelStyle(ListRowCenteredLabelStyle())
.alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
}
var avatarBody: some View {
Label {
titleAndDescription
} icon: {
icon // Layout handled by the style.
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .leading)
.labelStyle(ListRowAvatarLabelStyle())
}
var titleAndDescription: some View {
VStack(alignment: .leading, spacing: 2) {
if let title {
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text(title)
.font(.compound.bodyLG)
.foregroundColor(titleColor)
.lineLimit(titleLineLimit)
// Status is only available in the avatar init which requires a title,
// so no need to worry about the outer `if let` being nil in this instance.
if let status {
Text(status)
.font(.compound.bodySM)
.foregroundColor(statusColor)
.lineLimit(1)
}
}
}
if let description {
HStack(alignment: .top, spacing: 4) {
if role == .error {
CompoundIcon(\.errorSolid, size: .xSmall, relativeTo: .compound.bodySM)
.foregroundStyle(.compound.iconCriticalPrimary)
}
Text(description)
.font(.compound.bodySM)
.foregroundColor(descriptionColor)
.lineLimit(descriptionLineLimit)
}
}
}
.accessibilityElement(children: .combine)
}
// MARK: - Initialisers
public static func `default`(title: String,
description: String? = nil,
icon: Icon,
role: ListRowLabel.Role? = nil,
iconAlignment: VerticalAlignment = .center) -> ListRowLabel {
ListRowLabel(title: title,
description: description,
icon: icon,
role: role,
iconAlignment: iconAlignment)
}
public static func `default`(title: String,
description: String? = nil,
icon: KeyPath<CompoundIcons, Image>,
role: ListRowLabel.Role? = nil,
iconAlignment: VerticalAlignment = .center) -> ListRowLabel where Icon == CompoundIcon {
.default(title: title,
description: description,
icon: CompoundIcon(icon),
role: role,
iconAlignment: iconAlignment)
}
public static func `default`(title: String,
description: String? = nil,
systemIcon: SFSymbol,
role: ListRowLabel.Role? = nil,
iconAlignment: VerticalAlignment = .center) -> ListRowLabel where Icon == Image {
.default(title: title,
description: description,
icon: Image(systemSymbol: systemIcon),
role: role,
iconAlignment: iconAlignment)
}
public static func action(title: String,
icon: Icon,
role: ListRowLabel.Role? = nil) -> ListRowLabel {
ListRowLabel(title: title,
icon: icon,
role: role,
hideIconBackground: true)
}
public static func action(title: String,
icon: KeyPath<CompoundIcons, Image>,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == CompoundIcon {
.action(title: title, icon: CompoundIcon(icon), role: role)
}
public static func action(title: String,
systemIcon: SFSymbol,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == Image {
.action(title: title, icon: Image(systemSymbol: systemIcon), role: role)
}
public static func centeredAction(title: String,
icon: Icon,
role: ListRowLabel.Role? = nil) -> ListRowLabel {
ListRowLabel(title: title,
icon: icon,
role: role,
hideIconBackground: true,
layout: .centered)
}
public static func centeredAction(title: String,
icon: KeyPath<CompoundIcons, Image>,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == CompoundIcon {
.centeredAction(title: title, icon: CompoundIcon(icon), role: role)
}
public static func centeredAction(title: String,
systemIcon: SFSymbol,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == Image {
.centeredAction(title: title, icon: Image(systemSymbol: systemIcon), role: role)
}
public static func plain(title: String,
description: String? = nil,
role: ListRowLabel.Role? = nil) -> ListRowLabel where Icon == EmptyView {
ListRowLabel(title: title, description: description, role: role, hideIconBackground: true)
}
public static func description(_ description: String) -> ListRowLabel where Icon == EmptyView {
ListRowLabel(description: description)
}
/// A label that displays an avatar as it's icon, such as a user profile row or for a room picker.
public static func avatar(title: String,
status: String? = nil,
description: String? = nil,
icon: Icon,
role: ListRowLabel.Role? = nil) -> ListRowLabel {
ListRowLabel(title: title,
status: status,
description: description,
icon: icon,
role: role,
layout: .avatar)
}
}
// MARK: - Previews
struct ListRowLabel_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
Section {
Group {
ListRowLabel.default(title: "Person", icon: Image(systemName: "person"))
ListRowLabel.default(title: "Help",
description: "Supporting text",
systemIcon: .questionmarkCircle)
ListRowLabel.default(title: "Trash",
icon: Image(systemName: "trash"),
role: .destructive)
}
Group {
ListRowLabel.action(title: "Camera",
icon: Image(systemName: "camera"))
ListRowLabel.action(title: "Remove",
icon: Image(systemName: "person.badge.minus"),
role: .destructive)
ListRowLabel.centeredAction(title: "Person",
icon: Image(systemName: "person"))
ListRowLabel.centeredAction(title: "Remove",
systemIcon: .personBadgeMinus,
role: .destructive)
}
Group {
ListRowLabel.plain(title: "Person")
ListRowLabel.plain(title: "Remove",
role: .destructive)
ListRowLabel.plain(title: "Plain", description: "Description")
}
ListRowLabel.description("This is a row in the list, that only contains a description and doesn't have either an icon or a title.")
}
.listRowInsets(EdgeInsets())
Section {
ListRowLabel.avatar(title: "Alice",
description: "@alice:example.com",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background))
ListRowLabel.avatar(title: "Alice",
status: "Pending",
description: "@alice:example.com",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background))
ListRowLabel.avatar(title: "@bob:idontexist.com",
description: "This user can't be found, so the invite may not be received.",
icon: Circle().foregroundStyle(.compound.decorativeColors[0].background),
role: .error)
}
.listRowInsets(EdgeInsets())
Section {
ListRow(label: .description("This is a row in the list, with a multiline description but it doesn't have either an icon or a title, just this text here."),
kind: .label)
}
}
.compoundList()
.frame(idealHeight: 1000) // Snapshot height
.previewLayout(.sizeThatFits)
}
}

View File

@@ -0,0 +1,133 @@
//
// Copyright 2023, 2024 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
/// The spacing used inside of a ListRow
enum ListRowTrailingSectionSpacing {
static let horizontal = 8.0
}
/// The style applied to the details label in a list row's trailing section.
private struct ListRowDetailsLabelStyle: LabelStyle {
func makeBody(configuration: Configuration) -> some View {
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
configuration.title
.foregroundColor(.compound.textSecondary)
configuration.icon
.foregroundColor(.compound.iconPrimary)
}
.font(.compound.bodyLG)
}
}
/// The view shown to the right of the `ListRowLabel` inside of a `ListRow`.
/// This consists of both the `ListRowDetails` and the `ListRowAccessory`.
public struct ListRowTrailingSection<Icon: View>: View {
@Environment(\.isEnabled) private var isEnabled
private var title: String?
private var icon: Icon?
private var counter: Int?
private var isWaiting = false
private var accessory: ListRowAccessory?
@ScaledMetric private var iconSize = 24
private var hideAccessory: Bool { isWaiting && accessory?.kind == .unselected }
init(_ details: ListRowDetails<Icon>?, accessory: ListRowAccessory? = nil) {
title = details?.title
icon = details?.icon
isWaiting = details?.isWaiting ?? false
counter = details?.counter
self.accessory = accessory
}
public var body: some View {
HStack(spacing: ListRowTrailingSectionSpacing.horizontal) {
if isWaiting {
ProgressView()
}
if title != nil || icon != nil {
Label {
title.map(Text.init)
} icon: {
icon
}
.labelStyle(ListRowDetailsLabelStyle())
}
if let counter {
Text("\(counter)")
.font(.compound.bodyLG)
.foregroundStyle(.compound.textOnSolidPrimary)
.padding(.horizontal, 8)
.padding(.vertical, 2)
.background { Capsule().fill(isEnabled ? .compound.iconSuccessPrimary : .compound.iconDisabled) }
}
if let accessory, !hideAccessory {
accessory
}
}
.frame(minWidth: iconSize)
.accessibilityElement(children: .combine)
}
}
// MARK: - Previews
struct ListRowTrailingSection_Previews: PreviewProvider, TestablePreview {
static let someCondition = true
static let otherCondition = true
static var previews: some View {
VStack(spacing: 40) {
details
withAccessory
}
}
static var details: some View {
VStack(spacing: 20) {
ListRowTrailingSection(.label(title: "Content", icon: Image(systemName: "square.dashed")))
ListRowTrailingSection(.label(title: "Content", systemIcon: .squareDashed))
ListRowTrailingSection(.title("Content"))
ListRowTrailingSection(.icon(Image(systemName: "square.dashed")))
ListRowTrailingSection(.systemIcon(.squareDashed))
ListRowTrailingSection(.isWaiting(true))
ListRowTrailingSection(.systemIcon(.checkmark))
ListRowTrailingSection(.title("Hello"))
ListRowTrailingSection(someCondition ? .isWaiting(true) : otherCondition ? .systemIcon(.checkmark) : .title("Hello"))
ListRowTrailingSection(.title("Hello", counter: 1))
ListRowTrailingSection(.title("Hello", counter: 1))
.disabled(true)
}
}
static var withAccessory: some View {
VStack(spacing: 20) {
ListRowTrailingSection(.isWaiting(true), accessory: .selection(true))
.border(.purple)
// The checkmark should be hidden.
ListRowTrailingSection(.isWaiting(true), accessory: .selection(false))
.border(.purple)
// The checkmark's space should be reserved.
ListRowTrailingSection(.isWaiting(false), accessory: .selection(false))
.border(.purple)
ListRowTrailingSection(.counter(1), accessory: .navigationLink)
.border(.purple)
}
}
}

View File

@@ -0,0 +1,88 @@
//
// Copyright 2023, 2024 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 View {
/// Styles a list using the Compound design tokens.
func compoundList() -> some View {
environment(\.defaultMinListRowHeight, 48)
.scrollContentBackground(.hidden)
.background(Color.compound.bgSubtleSecondaryLevel0.ignoresSafeArea())
}
/// Styles a list section header using the Compound design tokens.
func compoundListSectionHeader() -> some View {
font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.listRowInsets(EdgeInsets(top: 15,
leading: ListRowPadding.horizontal,
bottom: 8,
trailing: ListRowPadding.horizontal))
}
/// Styles a list section footer using the Compound design tokens.
func compoundListSectionFooter() -> some View {
font(.compound.bodySM)
.foregroundColor(.compound.textSecondary)
.listRowInsets(EdgeInsets(top: 8,
leading: ListRowPadding.horizontal,
bottom: 10,
trailing: ListRowPadding.horizontal))
}
}
struct ListTextStyles_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
Form {
Section {
ListRow(label: .plain(title: "Hi!"), kind: .label)
} footer: {
Text("This is a footer down ere")
.compoundListSectionFooter()
}
Section {
ListRow(label: .plain(title: "Second!"), kind: .label)
} header: {
Text("Section Title")
.compoundListSectionHeader()
}
Section {
ListRow(label: .plain(title: "Third!"), kind: .label)
} header: {
Text("Section Title")
.compoundListSectionHeader()
}
Section {
ListRow(label: .plain(title: "I was slow, I'm last."), kind: .label)
} footer: {
Text("This is a footer down ere")
.compoundListSectionFooter()
}
}
.compoundList()
.previewDisplayName("Form")
List {
Section {
ListRow(label: .plain(title: "Hello"), kind: .label)
ListRow(label: .plain(title: "World!"), kind: .label)
} header: {
Text("Section Title")
.compoundListSectionHeader()
} footer: {
Text("Section footer")
.compoundListSectionFooter()
}
}
.compoundList()
.previewDisplayName("List")
}
}

View File

@@ -0,0 +1,42 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
struct SnapshotPrecisionPreferenceKey: PreferenceKey {
static var defaultValue: Float = 1.0
static func reduce(value: inout Float, nextValue: () -> Float) {
value = nextValue()
}
}
struct SnapshotPerceptualPrecisionPreferenceKey: PreferenceKey {
static var defaultValue: Float = 0.98
static func reduce(value: inout Float, nextValue: () -> Float) {
value = nextValue()
}
}
extension SwiftUI.View {
/// Use this modifier when you want to apply snapshot-specific preferences,
/// like delay and precision, to the view.
/// These preferences can then be retrieved and used elsewhere in your view hierarchy.
///
/// - Parameters:
/// - delay: The delay time in seconds that you want to set as a preference to the View.
/// - precision: The percentage of pixels that must match.
/// - perceptualPrecision: The percentage a pixel must match the source pixel to be considered a match. 98-99% mimics the precision of the human eye.
func snapshotPreferences(expect fulfillmentPublisher: (any Publisher<Bool, Never>)? = nil,
precision: Float = 1.0,
perceptualPrecision: Float = 0.98) -> some SwiftUI.View {
preference(key: SnapshotPrecisionPreferenceKey.self, value: precision)
.preference(key: SnapshotPerceptualPrecisionPreferenceKey.self, value: perceptualPrecision)
}
}

View File

@@ -0,0 +1,8 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
protocol TestablePreview { }

View File

@@ -0,0 +1,102 @@
//
// Copyright 2023, 2024 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
import SwiftUIIntrospect
public extension View {
/// Styles a search bar text field using the Compound design tokens.
/// This modifier is to be used in combination with `.searchable`.
@MainActor
@ViewBuilder
func compoundSearchField() -> some View {
if #available(iOS 26, *) {
self
} else {
introspect(.navigationStack, on: .supportedVersions, scope: .ancestor) { navigationController in
// Uses the navigation stack as .searchField is unreliable when pushing the second search bar, during the create rooms flow.
guard let searchController = navigationController.navigationBar.topItem?.searchController else { return }
// Ported from Riot iOS as this is the only reliable way to get the exact look we want.
// However this is fragile and tied to gutwrenching the current UISearchBar internals.
let textColor = UIColor.compound.textPrimary
let placeholderColor = UIColor.compound.textSecondary
let textFieldTintColor = UIColor.compound.iconAccentTertiary
let textFieldBackgroundColor = UIColor.compound._bgSubtleSecondaryAlpha
let searchTextField = searchController.searchBar.searchTextField
// Magnifying glass icon.
let leftImageView = searchTextField.leftView as? UIImageView
leftImageView?.tintColor = placeholderColor
// Placeholder text.
let placeholderLabel = searchTextField.value(forKey: "placeholderLabel") as? UILabel
placeholderLabel?.textColor = placeholderColor
// Clear button.
let clearButton = searchTextField.value(forKey: "clearButton") as? UIButton
let buttonImage = clearButton?.image(for: .normal)?.withRenderingMode(.alwaysTemplate)
clearButton?.setImage(buttonImage, for: .normal)
clearButton?.tintColor = placeholderColor
// Text field.
searchTextField.textColor = textColor
searchTextField.backgroundColor = textFieldBackgroundColor
searchTextField.tintColor = textFieldTintColor
// Hide the effect views so we can use the rounded rect style without any materials.
let effectBackgroundTop = searchTextField.value(forKey: "_effectBackgroundTop") as? UIView
effectBackgroundTop?.isHidden = true
let effectBackgroundBottom = searchTextField.value(forKey: "_effectBackgroundBottom") as? UIView
effectBackgroundBottom?.isHidden = false
}
}
}
}
// MARK: - Previews
struct SearchStyle_Previews: PreviewProvider, TestablePreview {
static var previews: some View {
NavigationStack {
List {
ForEach(0..<10, id: \.self) { index in
Text("Item \(index)")
}
}
.listStyle(.plain)
.searchable(text: .constant(""))
.compoundSearchField()
}
.tint(.compound.textActionPrimary)
.previewDisplayName("List")
NavigationStack {
Form {
Section {
ListRow(label: .plain(title: "Some row"),
kind: .button { })
} header: {
Text("Settings")
.compoundListSectionHeader()
}
Section {
ListRow(label: .plain(title: "Some setting"),
kind: .toggle(.constant(true)))
} header: {
Text("More Settings")
.compoundListSectionHeader()
}
}
.compoundList()
.searchable(text: .constant(""))
.compoundSearchField()
}
.tint(.compound.textActionPrimary)
.previewDisplayName("Form")
}
}

View File

@@ -0,0 +1,46 @@
//
// Copyright 2023, 2024 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 Foundation
@testable import Compound
import SwiftUI
import XCTest
final class DecorativeColorsTests: XCTestCase {
struct TestCase {
let input: String
private let webOutput: Int
// remember that web starts the index from 1 while we start from 0
var output: Int {
webOutput - 1
}
init(input: String, webOutput: Int) {
self.input = input
self.webOutput = webOutput
}
}
func testAvatarColorHash() {
// Match the tests with the web ones for consistency between the two platforms
// https://github.com/element-hq/compound-web/blob/4608dc807c9c904874eac67ff22be3213f4a261d/src/components/Avatar/Avatar.test.tsx#L62
let testCases: [TestCase] = [
.init(input: "@bob:example.org", webOutput: 4),
.init(input: "@alice:example.org", webOutput: 3),
.init(input: "@charlie:example.org", webOutput: 5),
.init(input: "@dan:example.org", webOutput: 4),
.init(input: "@elena:example.org", webOutput: 4),
.init(input: "@fanny:example.org", webOutput: 3)
]
for testCase in testCases {
XCTAssertEqual(Color.compound.decorativeColor(for: testCase.input), Color.compound.decorativeColors[testCase.output])
}
}
}

View File

@@ -0,0 +1,104 @@
//
// Copyright 2023, 2024 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.
//
@testable import Compound
import SwiftUI
import XCTest
final class FontSizeTests: XCTestCase {
/// Test all system text styles to assert mapping between `Font` and `UIFont`.
func testTextStyle() throws {
let caption2FontSize = FontSize.reflecting(.caption2)
XCTAssertEqual(caption2FontSize?.value, 11)
XCTAssertEqual(caption2FontSize?.style, .caption2)
let captionFontSize = FontSize.reflecting(.caption)
XCTAssertEqual(captionFontSize?.value, 12)
XCTAssertEqual(captionFontSize?.style, .caption)
let footnoteFontSize = FontSize.reflecting(.footnote)
XCTAssertEqual(footnoteFontSize?.value, 13)
XCTAssertEqual(footnoteFontSize?.style, .footnote)
let subheadlineFontSize = FontSize.reflecting(.subheadline)
XCTAssertEqual(subheadlineFontSize?.value, 15)
XCTAssertEqual(subheadlineFontSize?.style, .subheadline)
let calloutFontSize = FontSize.reflecting(.callout)
XCTAssertEqual(calloutFontSize?.value, 16)
XCTAssertEqual(calloutFontSize?.style, .callout)
let bodyFontSize = FontSize.reflecting(.body)
XCTAssertEqual(bodyFontSize?.value, 17)
XCTAssertEqual(bodyFontSize?.style, .body)
let headlineFontSize = FontSize.reflecting(.headline)
XCTAssertEqual(headlineFontSize?.value, 17)
XCTAssertEqual(headlineFontSize?.style, .headline)
let title3FontSize = FontSize.reflecting(.title3)
XCTAssertEqual(title3FontSize?.value, 20)
XCTAssertEqual(title3FontSize?.style, .title3)
let title2FontSize = FontSize.reflecting(.title2)
XCTAssertEqual(title2FontSize?.value, 22)
XCTAssertEqual(title2FontSize?.style, .title2)
let titleFontSize = FontSize.reflecting(.title)
XCTAssertEqual(titleFontSize?.value, 28)
XCTAssertEqual(titleFontSize?.style, .title)
let largeTitleFontSize = FontSize.reflecting(.largeTitle)
XCTAssertEqual(largeTitleFontSize?.value, 34)
XCTAssertEqual(largeTitleFontSize?.style, .largeTitle)
}
func testModifiedTextStyle() {
let boldCaptionFontSize = FontSize.reflecting(.caption.bold())
XCTAssertEqual(boldCaptionFontSize?.value, 12)
XCTAssertEqual(boldCaptionFontSize?.style, .caption)
let styledTitle = Font.title.width(.compressed).bold().italic().monospaced()
let styledTitleFontSize = FontSize.reflecting(styledTitle)
XCTAssertEqual(styledTitleFontSize?.value, 28)
XCTAssertEqual(styledTitleFontSize?.style, .title)
}
func testSystemFont() {
let system21FontSize = FontSize.reflecting(.system(size: 21))
XCTAssertEqual(system21FontSize?.value, 21)
let boldSystem29FontSize = FontSize.reflecting(.system(size: 29).bold())
XCTAssertEqual(boldSystem29FontSize?.value, 29)
let styledSystem33 = Font.system(size: 33).width(.compressed).bold().italic().monospacedDigit()
let styledSystem33FontSize = FontSize.reflecting(styledSystem33)
XCTAssertEqual(styledSystem33FontSize?.value, 33)
}
func testCustomFont() {
let custom43FontSize = FontSize.reflecting(.custom("Baskerville", size: 43))
XCTAssertEqual(custom43FontSize?.value, 43)
XCTAssertEqual(custom43FontSize?.style, .body)
let styledCustom35 = Font.custom("Baskerville", size: 35).weight(.thin).monospaced().italic()
let styledCustom35FontSize = FontSize.reflecting(styledCustom35)
XCTAssertEqual(styledCustom35FontSize?.value, 35)
XCTAssertEqual(styledCustom35FontSize?.style, .body)
}
func testCustomFontWithTextStyle() {
let customTitle21FontSize = FontSize.reflecting(.custom("Baskerville", size: 21, relativeTo: .title))
XCTAssertEqual(customTitle21FontSize?.value, 21)
XCTAssertEqual(customTitle21FontSize?.style, .title)
let styledCustomFootnote15 = Font.custom("Baskerville", size: 15, relativeTo: .footnote).weight(.thin).monospaced().italic()
let styledCustomFootnote15FontSize = FontSize.reflecting(styledCustomFootnote15)
XCTAssertEqual(styledCustomFootnote15FontSize?.value, 15)
XCTAssertEqual(styledCustomFootnote15FontSize?.style, .footnote)
}
}

View File

@@ -0,0 +1,112 @@
// Generated using Sourcery 2.2.6 https://github.com/krzysztofzablocki/Sourcery
// DO NOT EDIT
// swiftlint:disable all
// swiftformat:disable all
import XCTest
@testable import Compound
extension PreviewTests {
// MARK: - PreviewProvider
func testCompoundButtonStyle() async throws {
for (index, preview) in CompoundButtonStyle_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testCompoundIcon() async throws {
for (index, preview) in CompoundIcon_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testCompoundToggleStyle() async throws {
for (index, preview) in CompoundToggleStyle_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListInlinePicker() async throws {
for (index, preview) in ListInlinePicker_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListRowAccessory() async throws {
for (index, preview) in ListRowAccessory_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListRowButtonStyle() async throws {
for (index, preview) in ListRowButtonStyle_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListRowLabel() async throws {
for (index, preview) in ListRowLabel_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListRowLoadingSelection() async throws {
for (index, preview) in ListRowLoadingSelection_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListRowTrailingSection() async throws {
for (index, preview) in ListRowTrailingSection_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListRow() async throws {
for (index, preview) in ListRow_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testListTextStyles() async throws {
for (index, preview) in ListTextStyles_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testScaledFrameModifier() async throws {
for (index, preview) in ScaledFrameModifier_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testScaledOffsetModifier() async throws {
for (index, preview) in ScaledOffsetModifier_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testScaledPaddingModifier() async throws {
for (index, preview) in ScaledPaddingModifier_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testSearchStyle() async throws {
for (index, preview) in SearchStyle_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
func testSendButton() async throws {
for (index, preview) in SendButton_Previews._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
}
// swiftlint:enable all
// swiftformat:enable all

View File

@@ -0,0 +1,37 @@
//
// Copyright 2024 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 Foundation
@testable import Compound
import XCTest
class OverrideColorTests: XCTestCase {
func testSwiftUI() {
let colors = CompoundColors()
let tokens = CompoundColorTokens()
XCTAssertEqual(colors.textPrimary, tokens.textPrimary)
colors.override(\.textPrimary, with: .pink)
XCTAssertEqual(colors.textPrimary, .pink)
colors.override(\.textPrimary, with: nil)
XCTAssertEqual(colors.textPrimary, tokens.textPrimary)
}
func testUIKit() {
let colors = CompoundUIColors()
let tokens = CompoundUIColorTokens()
XCTAssertEqual(colors.textPrimary, tokens.textPrimary)
colors.override(\.textPrimary, with: .systemPink)
XCTAssertEqual(colors.textPrimary, .systemPink)
colors.override(\.textPrimary, with: nil)
XCTAssertEqual(colors.textPrimary, tokens.textPrimary)
}
}

View File

@@ -0,0 +1,238 @@
//
// Copyright 2025 New Vector Ltd
//
// SPDX-License-Identifier: AGPL-3.0-only
// Please see LICENSE in the repository root for full details.
//
import Combine
import SwiftUI
import XCTest
@testable import Compound
@testable import SnapshotTesting
@MainActor
class PreviewTests: XCTestCase {
private let deviceConfig: ViewImageConfig = .iPhoneX
private let simulatorDevice: String? = "iPhone14,6" // iPhone SE 3rd Generation
private let requiredOSVersion = (major: 18, minor: 5)
private let snapshotDevices = ["iPhone 16", "iPad"]
private var recordMode: SnapshotTestingConfiguration.Record = .missing
override func setUp() {
super.setUp()
if ProcessInfo().environment["RECORD_FAILURES"].map(Bool.init) == true {
recordMode = .failed
}
checkEnvironments()
UIView.setAnimationsEnabled(false)
}
/// Check environments to avoid problems with snapshots on different devices or OS.
private func checkEnvironments() {
if let simulatorDevice {
let deviceModel = ProcessInfo().environment["SIMULATOR_MODEL_IDENTIFIER"]
guard deviceModel?.contains(simulatorDevice) ?? false else {
fatalError("\(deviceModel ?? "Unknown") is the wrong one. Switch to using \(simulatorDevice) for these tests.")
}
}
let osVersion = ProcessInfo().operatingSystemVersion
guard osVersion.majorVersion == requiredOSVersion.major, osVersion.minorVersion == requiredOSVersion.minor else {
fatalError("Switch to iOS \(requiredOSVersion) for these tests.")
}
guard !snapshotDevices.isEmpty else {
fatalError("Specify at least one snapshot device to test on.")
}
}
// MARK: - Snapshots
func assertSnapshots(matching preview: _Preview, testName: String = #function, step: Int) async throws {
let preferences = SnapshotPreferences()
let preferenceReadingView = preview.content
.onPreferenceChange(SnapshotPrecisionPreferenceKey.self) { preferences.precision = $0 }
.onPreferenceChange(SnapshotPerceptualPrecisionPreferenceKey.self) { preferences.perceptualPrecision = $0 }
// Render an image of the view in order to trigger the preference updates to occur.
let imageRenderer = ImageRenderer(content: preferenceReadingView)
_ = imageRenderer.uiImage
var sanitizedSuiteName = String(testName.suffix(testName.count - "test".count).dropLast(2))
sanitizedSuiteName = sanitizedSuiteName.prefix(1).lowercased() + sanitizedSuiteName.dropFirst()
for deviceName in snapshotDevices {
guard var device = PreviewDevice(rawValue: deviceName).snapshotDevice() else {
fatalError("Unknown device name: \(deviceName)")
}
// Ignore specific device safe area (using the workaround value to fix rendering issues).
device.safeArea = .one
// Ignore specific device display scale
let traits = UITraitCollection(displayScale: 2.0)
var testName = ""
if let displayName = preview.displayName {
testName = "\(displayName)-\(deviceName)-\(localeCode)"
} else {
testName = "\(deviceName)-\(localeCode)-\(step)"
}
let isScreen = switch preview.layout {
case .device: true
default: false
}
if let failure = assertSnapshots(matching: preview.content,
name: testName,
isScreen: isScreen,
device: device,
testName: sanitizedSuiteName,
traits: traits,
preferences: preferences) {
XCTFail(failure)
}
}
}
private var localeCode: String {
if UserDefaults.standard.bool(forKey: "NSDoubleLocalizedStrings") {
return "pseudo"
}
return languageCode + "-" + regionCode
}
private var languageCode: String {
Locale.current.language.languageCode?.identifier ?? ""
}
private var regionCode: String {
Locale.current.language.region?.identifier ?? ""
}
private func assertSnapshots(matching view: AnyView,
name: String?,
isScreen: Bool,
device: ViewImageConfig,
testName: String = #function,
traits: UITraitCollection = .init(),
preferences: SnapshotPreferences) -> String? {
let matchingView = isScreen ? AnyView(view) : AnyView(view
.frame(width: device.size?.width)
.fixedSize(horizontal: false, vertical: true)
)
return withSnapshotTesting(record: recordMode) {
verifySnapshot(of: matchingView,
as: .prefireImage(preferences: preferences,
layout: isScreen ? .device(config: device) : .sizeThatFits,
traits: traits),
named: name,
testName: testName)
}
}
private func wait(for duration: TimeInterval) {
let expectation = XCTestExpectation(description: "Wait")
DispatchQueue.main.asyncAfter(deadline: .now() + duration) {
expectation.fulfill()
}
_ = XCTWaiter.wait(for: [expectation], timeout: duration + 1)
}
}
private class SnapshotPreferences: @unchecked Sendable {
var precision: Float = 1
var perceptualPrecision: Float = 1
var fulfillmentPublisher: AnyPublisher<Bool, Never>?
}
// MARK: - SnapshotTesting + Extensions
private extension PreviewDevice {
func snapshotDevice() -> ViewImageConfig? {
switch rawValue {
case "iPhone 16", "iPhone 15", "iPhone 14", "iPhone 13", "iPhone 12", "iPhone 11", "iPhone 10":
return .iPhoneX
case "iPhone 6", "iPhone 6s", "iPhone 7", "iPhone 8":
return .iPhone8
case "iPhone 6 Plus", "iPhone 6s Plus", "iPhone 8 Plus":
return .iPhone8Plus
case "iPhone SE (1st generation)", "iPhone SE (2nd generation)":
return .iPhoneSe
case "iPad":
return .iPad10_2
case "iPad Mini":
return .iPadMini
case "iPad Pro 11":
return .iPadPro11
case "iPad Pro 12.9":
return .iPadPro12_9
default: return nil
}
}
}
private extension Snapshotting where Value: SwiftUI.View, Format == UIImage {
static func prefireImage(drawHierarchyInKeyWindow: Bool = false,
preferences: SnapshotPreferences,
layout: SwiftUISnapshotLayout = .sizeThatFits,
traits: UITraitCollection = .init()) -> Snapshotting {
let config: ViewImageConfig
switch layout {
#if os(iOS) || os(tvOS)
case let .device(config: deviceConfig):
config = deviceConfig
#endif
case .sizeThatFits:
// Make sure to use the workaround safe area insets.
config = .init(safeArea: .one, size: nil, traits: traits)
case let .fixed(width: width, height: height):
let size = CGSize(width: width, height: height)
// Make sure to use the workaround safe area insets.
config = .init(safeArea: .one, size: size, traits: traits)
}
return SimplySnapshotting<UIImage>(pathExtension: "png", diffing: .prefireImage(preferences: preferences, scale: traits.displayScale))
.asyncPullback { view in
var config = config
let controller: UIViewController
if config.size != nil {
controller = UIHostingController(rootView: view)
} else {
let hostingController = UIHostingController(rootView: view)
let maxSize = CGSize.zero
config.size = hostingController.sizeThatFits(in: maxSize)
controller = hostingController
}
return snapshotView(config: config,
drawHierarchyInKeyWindow: drawHierarchyInKeyWindow,
traits: traits,
view: controller.view,
viewController: controller)
}
}
}
private extension Diffing where Value == UIImage {
static func prefireImage(preferences: SnapshotPreferences, scale: CGFloat?) -> Diffing {
lazy var originalDiffing = Diffing.image(precision: preferences.precision, perceptualPrecision: preferences.perceptualPrecision, scale: scale)
return Diffing(toData: { originalDiffing.toData($0) },
fromData: { originalDiffing.fromData($0) },
diff: { originalDiffing.diff($0, $1) })
}
}
private extension UIEdgeInsets {
/// A custom inset that prevents the snapshotting library from rendering the
/// origin at (10000, 10000) which breaks some of our views such as MessageText.
static var one: UIEdgeInsets { UIEdgeInsets(top: 1, left: 1, bottom: 1, right: 1) }
}

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1522abe037552d058d16a117a5aab3f8533b2a1f874c62fb3a467dbea5a75985
size 365301

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7495424229f590d4750e1263d942a84def528edba32bd2aee5ba805c9d9bd0bc
size 246031

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:ae7bc39ec3c30e24d8ac88c8d606f778802f0d4444ca5c500de1d325614e4021
size 143736

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:19c50858911f09d89a9ac7655c12d3d411075e19ae637cc9c7af8d2d74bd6abb
size 95797

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4ded596557ccd9661dcf7b56bd45afec80bf4e70fe44b0b7f5fe82c554e09d54
size 270756

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7ade49dc268c8a7ff62cacbd4d648121fa1fd488ac4f3b32edbd04dfb2191a31
size 151126

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6513d85e4d4bd56b1e23e1e6120a1ad1a10f18ae9a216d10e93a0e8fdf024510
size 27001

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:732408e6893cb40b5a8e3a85ebe334fc74aab6edf7306b1af0fb87cc7ff7e797
size 18848

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fd5f836f60f9a8b075033402c00f13aaac04062572e42bbc1d1fec04c8505ff3
size 97606

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:c3a2b451c5a074f5d6980d2be9376a41c5e19a3a35a0c503da02251a5079856e
size 50751

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:2ac4f5f9eb71287f49783e7cf36900ac7c9ff70c54eb29eba55a1c1ad1762f20
size 91361

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f4c5095c685fc5b68ecb54245b18deeb6036d1f55547546919e1a1e33cb40315
size 49337

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3945f7af1ab1a55166b574f58688889b174289c25638de8acf8d9f78c2c9f75f
size 109376

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:4bdc5025c6c745081e8e2766f54a148a568460ad18e139c0dea9939dff03319a
size 62084

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:01a4916af8da7fa798403e4678cd53f7f10352b5b1d87fef8594935bb61e4fe5
size 370719

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fba908f45758e56689c183c3636c2167cf389f1fb9a5c115276de9150949f1dd
size 250067

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f441c345728aff436af9f7aaf6c725cc6c303309915f63faac5726e4768ec495
size 115602

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7b38ad276f9d70ae600cbdb9ccc01861b584e30532f669895e009732838329ee
size 70069

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0c26d84c066dd85bccb5a5e7a08745803ba4de069493bbce5071b90c55d172cc
size 85789

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b1c2b14d75abcc6529963178e336a730bd899c4ca73b7b8e7f5589a25c2a2859
size 40657

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dccffd12a157f5456f479246044e043313c93e80edca6f822c72fc5b3b74b2f7
size 192255

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f94fc77bc6c85dd850148676053ce549a37486d44950254ce5a12497283bf278
size 135766

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:8470bb9adc78c11996a13a21b954d338f911604bb1370c8484d4ace61c897bed
size 134850

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3a9530ae1b21201bf606b46223ff1717a55c464e2b3e262f2e07031a61e2f0fc
size 100075

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7de1fe2709765858fbdfbbee27796f6899ae4ec3130997637a1f521ed9cec2c3
size 92210

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b81ef4ef0f7c7f701323daa06fe202055de4a32087142fcb1410255a28e7fb06
size 49332

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cb82201cb06d09d1a3e3d3ac9b78b0a5686cd1aa08a15445a61116f42bcaf746
size 102895

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5122eab1e08d9f3207410669d7ff1076f02b7df91c1d3cc49356dc7fb10fb705
size 57230

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:e9e66de21b08f5460d34a27026c7f80bf2fa30157920dd8576c222848b05408b
size 86910

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0b4a5b177ee1d57b7848eafed1d38a3768bf3310393802374244f76de48a82a2
size 40995

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:37c1b4ef394ebf21c35eb8dba7dd41cff59b264aec57de0fa8dcc0b368eec3b0
size 92691

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:73953b850cad43b8d09628143d3220441877bc3485dddaf734f14a3469778e8c
size 51105

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dee1d6e5f05fefaae3f2cdb8c5d7d942b23f3e5244a9c8589c6cfee1280c2b53
size 85357

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:cc49e6cf9dd0490434ee5a189240fe9e45688e0843755ae681a89d35b716944e
size 44655

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:f801b1baf3852a280e9edd8c21879f95c933b1f562fdf7db72b7dbcafc71925b
size 112184

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:721e0439a8ed8dbdc201cb618ddf157ac49dbf8f165a84cba932324b24f98252
size 69586

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1e410e13b6bfb668cc3593c746ef0bd48673ab32283630826130cabcc43d6f43
size 100011

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9cbb08c9fe6df9b564c93192aee5c655bf5c7ec69711fae822c7aea9e9a785a3
size 50053

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:1ffa8d0056bb4cee07ad513405eb3b3903e21cfbd945866c354b7e67fbdffe19
size 92428

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:dd3e0d853e76b65eab44bd0e52d0c468c4fcaf0f60021d74d68949628238acb5
size 49150

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:fa1eadbbf0b7afd190fc65855ff81650216fb4bbc188802a93f210a9a2c2a896
size 79859

View File

@@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:7404a991f29fb33fe7d3d7fbcb102d2e89ed4975d6cb1e125e4448a03a885e8a
size 39549

View File

@@ -0,0 +1,46 @@
// swiftlint:disable all
// swiftformat:disable all
import XCTest
@testable import Compound
{% if argument.mainTarget %}
@testable import {{ argument.mainTarget }}
{% endif %}
{% for import in argument.imports %}
{% if import != "last" %}
import {{ import }}
{% endif %}
{% endfor %}
{% for import in argument.testableImports %}
{% if import != "last" %}
@testable import {{ import }}
{% endif %}
{% endfor %}
extension PreviewTests {
{% if argument.file %}
private var file: StaticString { .init(stringLiteral: "{{ argument.file }}") }
{% endif %}
// MARK: - PreviewProvider
{% for type in types.types where (type.implements.TestablePreview or type.based.TestablePreview or type|annotated:"TestablePreview") and type.name != "TestablePreview" %}
func test{{ type.name|replace:"_Previews", "" }}() async throws {
for (index, preview) in {{ type.name }}._allPreviews.enumerated() {
try await assertSnapshots(matching: preview, step: index)
}
}
{%- if not forloop.last %}
{% endif %}
{% endfor %}
{% if argument.previewsMacros %}
// MARK: - Macros
{{ argument.previewsMacros }}
{% endif %}
}
// swiftlint:enable all
// swiftformat:enable all

View File

@@ -0,0 +1,7 @@
sources:
include:
- ../../Sources
templates:
- PreviewTests.stencil
output:
../../Tests/CompoundTests/GeneratedPreviewTests.swift

View File

@@ -71,9 +71,7 @@ packages:
exactVersion: 25.09.19-2
# path: ../matrix-rust-sdk
Compound:
url: https://github.com/element-hq/compound-ios
revision: 16f4544d2823d590401f13da260e56b8674b66b3
# path: ../compound-ios
path: compound-ios
AnalyticsEvents:
url: https://github.com/matrix-org/matrix-analytics-events
minorVersion: 0.29.2