A library for helping build high quality forms in SwiftUI and TCA
0

Configure Feed

Select the types of activity you want to include in your feed.

Add ComposableForm preview

Woodrow Melling (May 23, 2026, 12:08 PM -0600) b6615fe1 2271322d

+225 -19
+1 -19
Package.resolved
··· 1 1 { 2 - "originHash" : "c7cc3e51dec9e71fa1e33c31c50eec71e391849eac1794fd6d502c5896594cf6", 2 + "originHash" : "7771c7fad33a4d9cafc53d24bbcfb9fd2b25e916e77436ad85c71f6cb5bd5ceb", 3 3 "pins" : [ 4 4 { 5 5 "identity" : "combine-schedulers", ··· 53 53 "state" : { 54 54 "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", 55 55 "version" : "1.0.2" 56 - } 57 - }, 58 - { 59 - "identity" : "skip-fuse-ui", 60 - "kind" : "remoteSourceControl", 61 - "location" : "https://source.skip.tools/skip-fuse-ui.git", 62 - "state" : { 63 - "revision" : "fe72e878d7538ed135e356353c57b5288249854a", 64 - "version" : "1.15.1" 65 56 } 66 57 }, 67 58 { ··· 161 152 "state" : { 162 153 "branch" : "main", 163 154 "revision" : "a4855f00fd5de436e9d1eb1e4ab98ed47f9155d1" 164 - } 165 - }, 166 - { 167 - "identity" : "swift-dependencies", 168 - "kind" : "remoteSourceControl", 169 - "location" : "https://github.com/pointfreeco/swift-dependencies", 170 - "state" : { 171 - "branch" : "26", 172 - "revision" : "929e0e2a83032fa50119457ae2963ac6eb313e69" 173 155 } 174 156 }, 175 157 {
+224
Sources/ComposableForms/FormView.swift
··· 110 110 ) 111 111 } 112 112 } 113 + 114 + #if DEBUG 115 + #Preview("Composable form") { 116 + ComposableFormPreview( 117 + store: Store( 118 + initialState: PreviewProfileForm.State( 119 + value: PreviewProfileDraft.sample 120 + ) 121 + ) { 122 + PreviewProfileForm() 123 + } 124 + ) 125 + } 126 + 127 + private struct ComposableFormPreview: View { 128 + @Bindable var store: StoreOf<PreviewProfileForm> 129 + 130 + var body: some View { 131 + let emailValidation = store.validation[PreviewProfileFieldID.email] 132 + let nameValidation = store.validation[PreviewProfileFieldID.name] 133 + let usernameValidation = store.validation[PreviewProfileFieldID.username] 134 + 135 + NavigationStack { 136 + ComposableForm(store) { 137 + FormField(\.name) { $name in 138 + TextField("Name", text: $name) 139 + .submitLabel(.next) 140 + PreviewFieldStatus(validation: nameValidation) 141 + } 142 + 143 + FormField(\.email) { $email in 144 + TextField("Email", text: $email) 145 + .submitLabel(.next) 146 + PreviewFieldStatus(validation: emailValidation) 147 + } 148 + 149 + FormField(\.status) { $status in 150 + Picker("Status", selection: $status) { 151 + ForEach(PreviewStatus.allCases) { status in 152 + Text(status.title).tag(status) 153 + } 154 + } 155 + } 156 + 157 + FormScope(\.account) { 158 + FormField(\.username) { $username in 159 + TextField("Username", text: $username) 160 + .submitLabel(.done) 161 + PreviewFieldStatus(validation: usernameValidation) 162 + } 163 + } 164 + } 165 + .navigationTitle("Profile Draft") 166 + .safeAreaInset(edge: .bottom) { 167 + self.footer 168 + } 169 + } 170 + } 171 + 172 + private var footer: some View { 173 + VStack(alignment: .leading, spacing: 12) { 174 + HStack { 175 + self.submissionStatus 176 + Spacer() 177 + Button("Submit") { 178 + store.send(.submittedForm) 179 + } 180 + .buttonStyle(.borderedProminent) 181 + .disabled(store.isSubmitting) 182 + } 183 + 184 + Text("Try clearing the name, entering an invalid email, or using `taken` as the username.") 185 + .font(.footnote) 186 + .foregroundStyle(.secondary) 187 + } 188 + .padding() 189 + .background(.bar) 190 + } 191 + 192 + private var submissionStatus: some View { 193 + HStack(spacing: 6) { 194 + if store.isSubmitting { 195 + ProgressView() 196 + } 197 + 198 + Text(self.submissionStatusLabel) 199 + .font(.subheadline.weight(.medium)) 200 + } 201 + } 202 + 203 + private var submissionStatusLabel: LocalizedStringKey { 204 + switch store.submissionStatus { 205 + case .idle: 206 + store.hasErrors ? "Needs changes" : "Ready" 207 + case .submitted: 208 + "Submitted" 209 + case .submitting: 210 + "Checking" 211 + } 212 + } 213 + } 214 + 215 + private struct PreviewFieldStatus: View { 216 + let validation: FieldValidationState? 217 + 218 + @ViewBuilder 219 + var body: some View { 220 + if validation?.isValidating == true { 221 + HStack(spacing: 6) { 222 + ProgressView() 223 + Text("Checking") 224 + .font(.footnote) 225 + .foregroundStyle(.secondary) 226 + } 227 + } 228 + 229 + ForEach(Array((validation?.errors ?? []).enumerated()), id: \.offset) { _, error in 230 + Text(error.message) 231 + .font(.footnote) 232 + .foregroundStyle(.red) 233 + } 234 + } 235 + } 236 + 237 + private typealias PreviewProfileForm = Form<PreviewProfileDraft> 238 + 239 + private struct PreviewAccountDraft: Equatable { 240 + var username = "" 241 + } 242 + 243 + @MainActor 244 + private enum PreviewProfileFieldID { 245 + static var account: AnyHashable { 246 + AnyHashable(\PreviewProfileDraft.account) 247 + } 248 + static var email: AnyHashable { 249 + AnyHashable(\PreviewProfileDraft.email) 250 + } 251 + static var name: AnyHashable { 252 + AnyHashable(\PreviewProfileDraft.name) 253 + } 254 + static var username: AnyHashable { 255 + AnyHashable( 256 + ScopedFormFieldID( 257 + scope: Self.account, 258 + field: AnyHashable(\PreviewAccountDraft.username) 259 + ) 260 + ) 261 + } 262 + } 263 + 264 + private struct PreviewProfileDraft: Equatable { 265 + var account = PreviewAccountDraft() 266 + var email = "" 267 + var name = "" 268 + var status = PreviewStatus.draft 269 + 270 + static let sample = Self( 271 + account: PreviewAccountDraft(username: "woody"), 272 + email: "woody@example.com", 273 + name: "Woody", 274 + status: .draft 275 + ) 276 + } 277 + 278 + private enum PreviewStatus: String, CaseIterable, Identifiable { 279 + case archived 280 + case draft 281 + case published 282 + 283 + var id: Self { self } 284 + 285 + var title: String { 286 + switch self { 287 + case .archived: 288 + "Archived" 289 + case .draft: 290 + "Draft" 291 + case .published: 292 + "Published" 293 + } 294 + } 295 + } 296 + 297 + private extension PreviewProfileForm { 298 + init() { 299 + self = Self { 300 + Self.Field(\.name) { 301 + Validation(error: .previewNameRequired) { !$0.isEmpty } 302 + } 303 + 304 + Self.Field(\.email) { 305 + Validation(error: .previewEmailRequired) { !$0.isEmpty } 306 + Validation(error: .previewEmailInvalid) { $0.contains("@") } 307 + } 308 + 309 + Self.Field(\.status) 310 + 311 + Self.Scope(\.account) { 312 + Form<PreviewAccountDraft>.Field(\.username) { 313 + Validation(error: .previewUsernameRequired) { !$0.isEmpty } 314 + Validation(policy: .formSubmitted) { username async throws -> ValidationResult in 315 + try await Task.sleep(for: .milliseconds(600)) 316 + return username.lowercased() == "taken" 317 + ? .invalid(.previewUsernameTaken) 318 + : .valid 319 + } 320 + } 321 + } 322 + } 323 + .onSubmit { value in 324 + print("Submitted profile draft:", value) 325 + } 326 + } 327 + } 328 + 329 + private extension ErrorMessage { 330 + static let previewEmailInvalid = Self(message: "Email must contain @") 331 + static let previewEmailRequired = Self(message: "Email is required") 332 + static let previewNameRequired = Self(message: "Name is required") 333 + static let previewUsernameRequired = Self(message: "Username is required") 334 + static let previewUsernameTaken = Self(message: "Username is already taken") 335 + } 336 + #endif