Move compound-ios package into the project.
This commit is contained in:
3
.gitattributes
vendored
3
.gitattributes
vendored
@@ -1,4 +1,5 @@
|
|||||||
UITests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text
|
UITests/Sources/__Snapshots__/** filter=lfs diff=lfs merge=lfs -text
|
||||||
DevelopmentAssets/Media/** 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
|
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
|
||||||
|
|||||||
@@ -1612,6 +1612,7 @@
|
|||||||
16D09C79746BDCD9173EB3A7 /* RoomDetailsEditScreenModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RoomDetailsEditScreenModels.swift; sourceTree = "<group>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
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>"; };
|
181CF280BC8E3F335AFCB4B8 /* RemotePreferenceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RemotePreferenceTests.swift; sourceTree = "<group>"; };
|
||||||
@@ -3825,6 +3826,7 @@
|
|||||||
A8002CB4F20B6282850A614C /* DevelopmentAssets */,
|
A8002CB4F20B6282850A614C /* DevelopmentAssets */,
|
||||||
2197234282B4BC0CE79AAC74 /* Secrets */,
|
2197234282B4BC0CE79AAC74 /* Secrets */,
|
||||||
823ED0EC3F1B6CF47D284011 /* Tools */,
|
823ED0EC3F1B6CF47D284011 /* Tools */,
|
||||||
|
9413F680ECDFB2B0DDB0DEF2 /* Packages */,
|
||||||
681566846AF307E9BA4C72C6 /* Products */,
|
681566846AF307E9BA4C72C6 /* Products */,
|
||||||
);
|
);
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
@@ -5144,6 +5146,14 @@
|
|||||||
path = UserProfileScreen;
|
path = UserProfileScreen;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
9413F680ECDFB2B0DDB0DEF2 /* Packages */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
174E4AEF3DED300AA81046EC /* compound-ios */,
|
||||||
|
);
|
||||||
|
name = Packages;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
948DD12A5533BE1BC260E437 /* LocationSharing */ = {
|
948DD12A5533BE1BC260E437 /* LocationSharing */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@@ -6871,7 +6881,6 @@
|
|||||||
AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */,
|
AC3475112CA40C2C6E78D1EB /* XCRemoteSwiftPackageReference "matrix-analytics-events" */,
|
||||||
4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
|
4A8D3ABF18EABB8066BBD46E /* XCRemoteSwiftPackageReference "swift-async-algorithms" */,
|
||||||
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */,
|
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */,
|
||||||
F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */,
|
|
||||||
4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */,
|
4C34425923978C97409A3EF2 /* XCRemoteSwiftPackageReference "DSWaveformImage" */,
|
||||||
C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */,
|
C13F55E4518415CB4C278E73 /* XCRemoteSwiftPackageReference "DTCoreText" */,
|
||||||
D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */,
|
D5F7D47BBAAE0CF1DDEB3034 /* XCRemoteSwiftPackageReference "DeviceKit" */,
|
||||||
@@ -6894,6 +6903,7 @@
|
|||||||
6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */,
|
6582B5AF3F104B0F7E031E7D /* XCRemoteSwiftPackageReference "SwiftState" */,
|
||||||
EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */,
|
EC6D0C817B1C21D9D096505A /* XCRemoteSwiftPackageReference "Version" */,
|
||||||
EE40B0E16A55BD23ECBFFD22 /* XCRemoteSwiftPackageReference "matrix-rich-text-editor-swift" */,
|
EE40B0E16A55BD23ECBFFD22 /* XCRemoteSwiftPackageReference "matrix-rich-text-editor-swift" */,
|
||||||
|
C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */,
|
||||||
);
|
);
|
||||||
preferredProjectObjectVersion = 77;
|
preferredProjectObjectVersion = 77;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@@ -9285,6 +9295,13 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
|
/* Begin XCLocalSwiftPackageReference section */
|
||||||
|
C89CF7729E028671C5DC461E /* XCLocalSwiftPackageReference "compound-ios" */ = {
|
||||||
|
isa = XCLocalSwiftPackageReference;
|
||||||
|
relativePath = "compound-ios";
|
||||||
|
};
|
||||||
|
/* End XCLocalSwiftPackageReference section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
/* Begin XCRemoteSwiftPackageReference section */
|
||||||
0CBF57301AA172C21F76CE86 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = {
|
0CBF57301AA172C21F76CE86 /* XCRemoteSwiftPackageReference "maplibre-gl-native-distribution" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
@@ -9486,14 +9503,6 @@
|
|||||||
version = 2.37.12;
|
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" */ = {
|
F76A08D0EA29A07A54F4EB4D /* XCRemoteSwiftPackageReference "swift-collections" */ = {
|
||||||
isa = XCRemoteSwiftPackageReference;
|
isa = XCRemoteSwiftPackageReference;
|
||||||
repositoryURL = "https://github.com/apple/swift-collections";
|
repositoryURL = "https://github.com/apple/swift-collections";
|
||||||
@@ -9512,7 +9521,6 @@
|
|||||||
};
|
};
|
||||||
07FEEEDB11543A7DED420F04 /* Compound */ = {
|
07FEEEDB11543A7DED420F04 /* Compound */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */;
|
|
||||||
productName = Compound;
|
productName = Compound;
|
||||||
};
|
};
|
||||||
0DD568A494247444A4B56031 /* Kingfisher */ = {
|
0DD568A494247444A4B56031 /* Kingfisher */ = {
|
||||||
@@ -9577,7 +9585,6 @@
|
|||||||
};
|
};
|
||||||
3262F08E1C3483C22A7A319F /* Compound */ = {
|
3262F08E1C3483C22A7A319F /* Compound */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */;
|
|
||||||
productName = Compound;
|
productName = Compound;
|
||||||
};
|
};
|
||||||
32B8F4CD937AA9C1F8FC3CBC /* KeychainAccess */ = {
|
32B8F4CD937AA9C1F8FC3CBC /* KeychainAccess */ = {
|
||||||
@@ -9832,7 +9839,6 @@
|
|||||||
};
|
};
|
||||||
DCA3C4A997AD28E6918D4CE5 /* Compound */ = {
|
DCA3C4A997AD28E6918D4CE5 /* Compound */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
package = F71C70A4404CC6D9C4AF35F2 /* XCRemoteSwiftPackageReference "compound-ios" */;
|
|
||||||
productName = Compound;
|
productName = Compound;
|
||||||
};
|
};
|
||||||
DE8DC9B3FBA402117DC4C49F /* Kingfisher */ = {
|
DE8DC9B3FBA402117DC4C49F /* Kingfisher */ = {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
{
|
{
|
||||||
"originHash" : "9d89a9320c29b16ca97c3b4c1dce15f8ab13ae1a66da50c98eec015db72c30d1",
|
"originHash" : "123c89d756b94a4cfdf4ca86fa4278640fc96854ac223bcef68e5566b0c47018",
|
||||||
"pins" : [
|
"pins" : [
|
||||||
{
|
{
|
||||||
"identity" : "compound-design-tokens",
|
"identity" : "compound-design-tokens",
|
||||||
@@ -10,14 +10,6 @@
|
|||||||
"version" : "6.0.0"
|
"version" : "6.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"identity" : "compound-ios",
|
|
||||||
"kind" : "remoteSourceControl",
|
|
||||||
"location" : "https://github.com/element-hq/compound-ios",
|
|
||||||
"state" : {
|
|
||||||
"revision" : "16f4544d2823d590401f13da260e56b8674b66b3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"identity" : "devicekit",
|
"identity" : "devicekit",
|
||||||
"kind" : "remoteSourceControl",
|
"kind" : "remoteSourceControl",
|
||||||
|
|||||||
68
compound-ios/Package.resolved
Normal file
68
compound-ios/Package.resolved
Normal 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
|
||||||
|
}
|
||||||
38
compound-ios/Package.swift
Normal file
38
compound-ios/Package.swift
Normal 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__"
|
||||||
|
]
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -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 )
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
69
compound-ios/Sources/Compound/Buttons/SendButton.swift
Normal file
69
compound-ios/Sources/Compound/Buttons/SendButton.swift
Normal 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 { }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
115
compound-ios/Sources/Compound/Colors/CompoundColors.swift
Normal file
115
compound-ios/Sources/Compound/Colors/CompoundColors.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
37
compound-ios/Sources/Compound/Colors/CompoundGradients.swift
Normal file
37
compound-ios/Sources/Compound/Colors/CompoundGradients.swift
Normal 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]) }
|
||||||
|
}
|
||||||
53
compound-ios/Sources/Compound/Colors/CompoundUIColors.swift
Normal file
53
compound-ios/Sources/Compound/Colors/CompoundUIColors.swift
Normal 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 }
|
||||||
|
}
|
||||||
@@ -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...)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
compound-ios/Sources/Compound/Fonts/CompoundFonts.swift
Normal file
52
compound-ios/Sources/Compound/Fonts/CompoundFonts.swift
Normal 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
|
||||||
|
}
|
||||||
72
compound-ios/Sources/Compound/Fonts/FontSize.swift
Normal file
72
compound-ios/Sources/Compound/Fonts/FontSize.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
250
compound-ios/Sources/Compound/Icons/CompoundIcon.swift
Normal file
250
compound-ios/Sources/Compound/Icons/CompoundIcon.swift
Normal 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 label’s 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 label’s 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
70
compound-ios/Sources/Compound/List/ListInlinePicker.swift
Normal file
70
compound-ios/Sources/Compound/List/ListInlinePicker.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
476
compound-ios/Sources/Compound/List/ListRow.swift
Normal file
476
compound-ios/Sources/Compound/List/ListRow.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
112
compound-ios/Sources/Compound/List/ListRowAccessory.swift
Normal file
112
compound-ios/Sources/Compound/List/ListRowAccessory.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
65
compound-ios/Sources/Compound/List/ListRowButtonStyle.swift
Normal file
65
compound-ios/Sources/Compound/List/ListRowButtonStyle.swift
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
94
compound-ios/Sources/Compound/List/ListRowDetails.swift
Normal file
94
compound-ios/Sources/Compound/List/ListRowDetails.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
373
compound-ios/Sources/Compound/List/ListRowLabel.swift
Normal file
373
compound-ios/Sources/Compound/List/ListRowLabel.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
133
compound-ios/Sources/Compound/List/ListRowTrailingSection.swift
Normal file
133
compound-ios/Sources/Compound/List/ListRowTrailingSection.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
88
compound-ios/Sources/Compound/List/ListStyles.swift
Normal file
88
compound-ios/Sources/Compound/List/ListStyles.swift
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
42
compound-ios/Sources/Compound/Previews/Snapshotting.swift
Normal file
42
compound-ios/Sources/Compound/Previews/Snapshotting.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 { }
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
46
compound-ios/Tests/CompoundTests/AvatarColorsTests.swift
Normal file
46
compound-ios/Tests/CompoundTests/AvatarColorsTests.swift
Normal 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])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
104
compound-ios/Tests/CompoundTests/FontSizeTests.swift
Normal file
104
compound-ios/Tests/CompoundTests/FontSizeTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
112
compound-ios/Tests/CompoundTests/GeneratedPreviewTests.swift
Normal file
112
compound-ios/Tests/CompoundTests/GeneratedPreviewTests.swift
Normal 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
|
||||||
37
compound-ios/Tests/CompoundTests/OverrideColorTests.swift
Normal file
37
compound-ios/Tests/CompoundTests/OverrideColorTests.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
238
compound-ios/Tests/CompoundTests/PreviewTests.swift
Normal file
238
compound-ios/Tests/CompoundTests/PreviewTests.swift
Normal 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) }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1522abe037552d058d16a117a5aab3f8533b2a1f874c62fb3a467dbea5a75985
|
||||||
|
size 365301
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7495424229f590d4750e1263d942a84def528edba32bd2aee5ba805c9d9bd0bc
|
||||||
|
size 246031
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:ae7bc39ec3c30e24d8ac88c8d606f778802f0d4444ca5c500de1d325614e4021
|
||||||
|
size 143736
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:19c50858911f09d89a9ac7655c12d3d411075e19ae637cc9c7af8d2d74bd6abb
|
||||||
|
size 95797
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4ded596557ccd9661dcf7b56bd45afec80bf4e70fe44b0b7f5fe82c554e09d54
|
||||||
|
size 270756
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7ade49dc268c8a7ff62cacbd4d648121fa1fd488ac4f3b32edbd04dfb2191a31
|
||||||
|
size 151126
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:6513d85e4d4bd56b1e23e1e6120a1ad1a10f18ae9a216d10e93a0e8fdf024510
|
||||||
|
size 27001
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:732408e6893cb40b5a8e3a85ebe334fc74aab6edf7306b1af0fb87cc7ff7e797
|
||||||
|
size 18848
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fd5f836f60f9a8b075033402c00f13aaac04062572e42bbc1d1fec04c8505ff3
|
||||||
|
size 97606
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:c3a2b451c5a074f5d6980d2be9376a41c5e19a3a35a0c503da02251a5079856e
|
||||||
|
size 50751
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:2ac4f5f9eb71287f49783e7cf36900ac7c9ff70c54eb29eba55a1c1ad1762f20
|
||||||
|
size 91361
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f4c5095c685fc5b68ecb54245b18deeb6036d1f55547546919e1a1e33cb40315
|
||||||
|
size 49337
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3945f7af1ab1a55166b574f58688889b174289c25638de8acf8d9f78c2c9f75f
|
||||||
|
size 109376
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:4bdc5025c6c745081e8e2766f54a148a568460ad18e139c0dea9939dff03319a
|
||||||
|
size 62084
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:01a4916af8da7fa798403e4678cd53f7f10352b5b1d87fef8594935bb61e4fe5
|
||||||
|
size 370719
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fba908f45758e56689c183c3636c2167cf389f1fb9a5c115276de9150949f1dd
|
||||||
|
size 250067
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f441c345728aff436af9f7aaf6c725cc6c303309915f63faac5726e4768ec495
|
||||||
|
size 115602
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7b38ad276f9d70ae600cbdb9ccc01861b584e30532f669895e009732838329ee
|
||||||
|
size 70069
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0c26d84c066dd85bccb5a5e7a08745803ba4de069493bbce5071b90c55d172cc
|
||||||
|
size 85789
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b1c2b14d75abcc6529963178e336a730bd899c4ca73b7b8e7f5589a25c2a2859
|
||||||
|
size 40657
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:dccffd12a157f5456f479246044e043313c93e80edca6f822c72fc5b3b74b2f7
|
||||||
|
size 192255
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f94fc77bc6c85dd850148676053ce549a37486d44950254ce5a12497283bf278
|
||||||
|
size 135766
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:8470bb9adc78c11996a13a21b954d338f911604bb1370c8484d4ace61c897bed
|
||||||
|
size 134850
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:3a9530ae1b21201bf606b46223ff1717a55c464e2b3e262f2e07031a61e2f0fc
|
||||||
|
size 100075
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7de1fe2709765858fbdfbbee27796f6899ae4ec3130997637a1f521ed9cec2c3
|
||||||
|
size 92210
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:b81ef4ef0f7c7f701323daa06fe202055de4a32087142fcb1410255a28e7fb06
|
||||||
|
size 49332
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cb82201cb06d09d1a3e3d3ac9b78b0a5686cd1aa08a15445a61116f42bcaf746
|
||||||
|
size 102895
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:5122eab1e08d9f3207410669d7ff1076f02b7df91c1d3cc49356dc7fb10fb705
|
||||||
|
size 57230
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:e9e66de21b08f5460d34a27026c7f80bf2fa30157920dd8576c222848b05408b
|
||||||
|
size 86910
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:0b4a5b177ee1d57b7848eafed1d38a3768bf3310393802374244f76de48a82a2
|
||||||
|
size 40995
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:37c1b4ef394ebf21c35eb8dba7dd41cff59b264aec57de0fa8dcc0b368eec3b0
|
||||||
|
size 92691
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:73953b850cad43b8d09628143d3220441877bc3485dddaf734f14a3469778e8c
|
||||||
|
size 51105
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:dee1d6e5f05fefaae3f2cdb8c5d7d942b23f3e5244a9c8589c6cfee1280c2b53
|
||||||
|
size 85357
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:cc49e6cf9dd0490434ee5a189240fe9e45688e0843755ae681a89d35b716944e
|
||||||
|
size 44655
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:f801b1baf3852a280e9edd8c21879f95c933b1f562fdf7db72b7dbcafc71925b
|
||||||
|
size 112184
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:721e0439a8ed8dbdc201cb618ddf157ac49dbf8f165a84cba932324b24f98252
|
||||||
|
size 69586
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1e410e13b6bfb668cc3593c746ef0bd48673ab32283630826130cabcc43d6f43
|
||||||
|
size 100011
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:9cbb08c9fe6df9b564c93192aee5c655bf5c7ec69711fae822c7aea9e9a785a3
|
||||||
|
size 50053
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:1ffa8d0056bb4cee07ad513405eb3b3903e21cfbd945866c354b7e67fbdffe19
|
||||||
|
size 92428
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:dd3e0d853e76b65eab44bd0e52d0c468c4fcaf0f60021d74d68949628238acb5
|
||||||
|
size 49150
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:fa1eadbbf0b7afd190fc65855ff81650216fb4bbc188802a93f210a9a2c2a896
|
||||||
|
size 79859
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
version https://git-lfs.github.com/spec/v1
|
||||||
|
oid sha256:7404a991f29fb33fe7d3d7fbcb102d2e89ed4975d6cb1e125e4448a03a885e8a
|
||||||
|
size 39549
|
||||||
46
compound-ios/Tools/Sourcery/PreviewTests.stencil
Normal file
46
compound-ios/Tools/Sourcery/PreviewTests.stencil
Normal 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
|
||||||
7
compound-ios/Tools/Sourcery/PreviewTestsConfig.yml
Normal file
7
compound-ios/Tools/Sourcery/PreviewTestsConfig.yml
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
sources:
|
||||||
|
include:
|
||||||
|
- ../../Sources
|
||||||
|
templates:
|
||||||
|
- PreviewTests.stencil
|
||||||
|
output:
|
||||||
|
../../Tests/CompoundTests/GeneratedPreviewTests.swift
|
||||||
@@ -71,9 +71,7 @@ packages:
|
|||||||
exactVersion: 25.09.19-2
|
exactVersion: 25.09.19-2
|
||||||
# path: ../matrix-rust-sdk
|
# path: ../matrix-rust-sdk
|
||||||
Compound:
|
Compound:
|
||||||
url: https://github.com/element-hq/compound-ios
|
path: compound-ios
|
||||||
revision: 16f4544d2823d590401f13da260e56b8674b66b3
|
|
||||||
# path: ../compound-ios
|
|
||||||
AnalyticsEvents:
|
AnalyticsEvents:
|
||||||
url: https://github.com/matrix-org/matrix-analytics-events
|
url: https://github.com/matrix-org/matrix-analytics-events
|
||||||
minorVersion: 0.29.2
|
minorVersion: 0.29.2
|
||||||
|
|||||||
Reference in New Issue
Block a user