diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1e0b0d3..6deaa17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,9 +9,6 @@ on: jobs: test: runs-on: macos-26 - # The Android half (Swift SDK install + cross-compile + Gradle/Robolectric) - # dominates the wall clock; the Apple half alone runs in ~2 minutes. - timeout-minutes: 90 steps: - uses: actions/checkout@v4 @@ -19,43 +16,19 @@ jobs: # uses: swift-actions/setup-swift@v2 # with: # swift-version: 6.2 - # (setup-skip below can also pin the host toolchain via its - # `swift-version` input, which routes through swiftly.) - # Robolectric against Android SDK 36 refuses to create a sandbox on - # anything below Java 21. The runner defaults to 17. - - name: Setup Java 21 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: '21' - - # Installs Homebrew, Gradle, the `skip` CLI, and the Swift SDK for - # Android. FormsKit builds in Skip's Fuse (native) mode, so the Android - # SDK is required: without it the skipstone plugin still generates the - # Kotlin peers and Gradle project, but `:FormsKit:buildLocalSwiftTestLibs` - # shells out to `skip` and dies with "command not found". - - name: Setup Skip - uses: skiptools/actions/setup-skip@v1 - with: - install-swift-android-sdk: 'true' - - # Apple build plus the Android cross-compile via the skipstone plugin. - name: Build run: swift build - # Runs both halves: the Apple test bundle and, through the SkipTest - # harness, the Android side under Gradle/Robolectric. - name: Test with coverage run: swift test --enable-code-coverage - # Coverage is Apple-side only; the Android run has no llvm profile. - name: Export coverage to lcov run: | BIN=$(swift build --show-bin-path) xcrun llvm-cov export \ "$BIN/FormsKitPackageTests.xctest/Contents/MacOS/FormsKitPackageTests" \ - -instr-profile "$(find .build -name default.profdata -type f | head -n1)" \ + -instr-profile "$(find .build -name default.profdata -type f)" \ -format lcov > coverage.lcov - name: Upload coverage report artifact diff --git a/.gitignore b/.gitignore index f7b276a..0023a53 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ .DS_Store /.build -/.build-zero /Packages xcuserdata/ DerivedData/ diff --git a/CLAUDE.md b/CLAUDE.md index 2888771..dac600e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,26 +6,21 @@ Guidance for Claude Code when working in this repository. `FormsKit` — a small, opinionated SwiftUI form-validation library. Ships a `@Validated` property wrapper, composable typed `ValidationRule`s, a `FormController` with a submission state machine, and four SwiftUI modifiers (`.formValidationError(for:)`, `.formToolbar(controller:onSubmit:)`, `.formBindFocus(_:on:)`, and `.focused(on:equals:)`). -Target audience: SwiftUI apps on iOS 17+ that use `@Observable` (not `ObservableObject`/Combine). Intentionally no Combine, no third-party runtime deps. Also compiles for Android as a [Skip](https://skip.dev) Fuse native framework (see "Skip / Android support" below). +Target audience: SwiftUI apps on iOS 17+ that use `@Observable` (not `ObservableObject`/Combine). Intentionally no Combine, no third-party deps. ## Build / test ```bash -swift build # Apple build; with Skip installed, also cross-compiles for Android via the skipstone plugin -swift test # Apple tests; with Skip installed, also builds+runs the Android side via Gradle/Robolectric -SKIP_ZERO=1 swift test # the pure-Apple, zero-dependency path (Skip plugin + deps stripped from the manifest) +swift build +swift test ``` -Package is `swift-tools-version: 6.3`, Swift 6 language mode, platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. The only dependencies are the Skip build-time packages (`skip`, `skip-fuse`, `skip-fuse-ui`), which the `SKIP_ZERO=1` manifest block removes entirely; don't add any others, and don't remove that block. - -Android test runs need a Gradle JVM ≥ 21 (Robolectric / Android SDK 36 requirement). `~/.gradle/gradle.properties` on this machine pins `org.gradle.java.home` to a Java 17 JBR; override per-run with `GRADLE_OPTS="-Dorg.gradle.java.home="` rather than editing the global file. +Package is `swift-tools-version: 6.3`, Swift 6 language mode, platforms iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1. No dependencies. Don't add any. ## Source layout ``` Sources/FormsKit/ -├── Skip/ -│ └── skip.yml # Skip config: native (Fuse) mode — see "Skip / Android support" ├── Validated.swift # @Validated property wrapper + State/Mode ├── ValidatedField.swift # type-erased schema entry for a Validated field ├── ValidationRule.swift # protocol ValidationRule @@ -38,12 +33,11 @@ Sources/FormsKit/ ├── ValidationRules/ │ ├── StringValidationRule.swift # protocol StringValidationRule │ └── StringValidationRules/ # concrete rules (NotEmpty, MinLength, …) -├── AnyFormController.swift # internal closure-erased facade over FormController for the bridged modifiers └── ViewModifiers/ - ├── FormValidationErrorModifier.swift # .formValidationError(for:) — single non-generic bridged modifier - ├── FormToolbarViewModifier.swift # .formToolbar(controller:onSubmit:) — generic modifier + bridged erased twin - ├── FormBindFocus.swift # .formBindFocus(_:on:) — direct composition, no struct - └── FocusedOnViewModifier.swift # .focused(on:equals:) — generic modifier + bridged erased twin + ├── FormValidationErrorModifier.swift # .formValidationError(for:) + ├── FormToolbarViewModifier.swift # .formToolbar(controller:onSubmit:) + ├── FormBindFocusViewModifier.swift # .formBindFocus(_:on:) + └── FocusedOnViewModifier.swift # .focused(on:equals:) ``` Keep one type per file. Group concrete rules under `ValidationRules/ValidationRules/` (currently only `String`; add `Number`, `Date`, etc. the same way if needed). The three form-conformance protocols live in `Forms/`; everything else is a high-visibility public type and stays at root. @@ -95,11 +89,11 @@ The library has a deliberate isolation shape; deviating from it will produce con - **Public surface, narrow.** Default to `internal`; mark `public` only what consumers must touch. The `name` field on `Validated` and the closures on `ValidatedField` intentionally stay non-public — consumers don't need them. - **No Combine.** Ever. `@Observable` only. -- **No third-party runtime dependencies.** Foundation + SwiftUI + Observation. The Skip packages are the single sanctioned exception: build-time only, inert on Apple platforms, and strippable via `SKIP_ZERO=1`. If any other feature seems to need a dep, find another way or push back. +- **No third-party dependencies.** Foundation + SwiftUI + Observation. If a feature seems to need a dep, find another way or push back. - **Rules are value types.** A `ValidationRule` impl is a plain struct with a `validate(value:) -> String?` method. Add a static factory on `ValidationRule where Self == YourRule` for call-site sugar (`.minLength(3)` style). Mirror the existing `MinStringLengthValidationRule` pattern. - **Rule error messages are passed in.** Don't hardcode user-facing strings inside rules beyond English defaults; consumers localize at call site by passing `message:`. (Localizing the package's own defaults via `String(localized:bundle: .module)` is a future improvement — track it as such, not as a quiet refactor.) - **`@Validated` mode default is `.onChange`.** Means "stay quiet until the field becomes `.invalid`, then re-validate on each keystroke." Don't change the default; it's the UX consumers expect. -- **View modifier UI is intentionally minimal.** `formValidationError` hardcodes `.red` and `.caption`; `FormToolbarViewModifier` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. +- **View modifier UI is intentionally minimal.** `FormValidationErrorModifier` hardcodes `.red` and `.caption`; `FormToolbarViewModifier` hardcodes English button titles + a discard dialog. Making these themeable / localizable is on the roadmap but hasn't shipped — don't sneak it in piecemeal; do it as one deliberate change with a public API. - **View modifiers prefixed `form*` are package-original concepts; unprefixed ones (e.g. `.focused(on:equals:)`) deliberately overload existing SwiftUI vocabulary.** Don't prefix the overloads (it breaks discovery via SwiftUI muscle memory); do prefix new concepts (it groups the package's surface in autocomplete). ## Focus support @@ -190,32 +184,6 @@ The `bind` in `.formBindFocus` reflects the bidirectional sync: writes to `$focu What's intentionally **not** in this slice: next/previous chevron buttons above the keyboard. That likely needs a `FocusableForm` protocol with an explicit `focusableFields: [PartialKeyPath]` so non-validated fields participate in ordered traversal. Defer until there's a concrete consumer need. -## Skip / Android support - -FormsKit ships as a Skip **Fuse (native) framework**: `Sources/FormsKit/Skip/skip.yml` declares `mode: 'native'`, so the Swift compiles as-is for Android with the Swift SDK for Android, and the SwiftUI layer resolves to SkipFuseUI → Compose. This is the only viable mode — Skip's *transpiled* mode supports neither custom property wrappers (`@Validated`) nor key paths (the `ValidatedField` schema and the whole focus system), so never attempt a transpiled port. - -Rules that keep the Android build green: - -- **Custom `ViewModifier`s work on Android only when bridged — and only non-generic types bridge.** On Android, SkipSwiftUI's `View.modifier(_:)` never calls `body(content:)` itself; it applies the modifier's `Java_modifier`, whose protocol-default implementation is `SkipUI.EmptyModifier()`. For a *bridged* modifier, skipstone generates the override (`Java_modifier { return self }` plus a Kotlin peer whose `body()` calls back into Swift), and the modifier renders. For an *unbridged* one — generic, `@nobridge`d, or hidden from the generator — the default fires and the modifier silently renders its content unchanged, dropping everything else (this is how the toolbar/validation/focus modifiers originally shipped as no-ops: they were generic, hence unbridgeable). Genericity is the trap, not the `ViewModifier` protocol. -- **Generic modifiers therefore come in dual form: a typed variant for non-bridge builds plus an erased bridged twin.** The generic variant lives in `#if !SKIP_BRIDGE && !SKIP` (Apple + SKIP_ZERO builds; the `!SKIP` half hides it from the skipstone generator, which parses with `SKIP` defined but `SKIP_BRIDGE` undefined). The erased twin (`ErasedFormToolbarModifier`, `ErasedFocusedOnModifier`) is declared **unconditionally** — the generator must see it to emit its Kotlin peer, and the Robolectric host build compiles the generated `*_Bridge.swift` against it — and erases `FormController` behind the internal `AnyFormController` closure facade (`AnyKeyPath` for focus identity). Erased twins must NOT carry `// SKIP @nobridge`, keep their memberwise inits internal (no constructor bridging), and their two bodies must be kept in sync by hand — the mirrored unit tests in `ViewModifierTests.swift` cover both variants. `skip.yml` sets `bridging: true` for the peers. -- **View files import SwiftUI behind `#if SKIP || SKIP_BRIDGE`, never plain `import SwiftUI`.** Every file declaring a `View` or `ViewModifier` opens with: - - ```swift - #if SKIP || SKIP_BRIDGE - import SkipSwiftUI - #else - import SwiftUI - #endif - ``` - - The generated `*_Bridge.swift` files mirror their source file's imports, and that module has to resolve to SkipSwiftUI — whose `SkipUIBridging`/`SkipUI` machinery the bridges reference — in bridge builds, and to real SwiftUI everywhere else. **Both disjuncts are load-bearing.** `SKIP` covers the skipstone generator, which parses with `SKIP` defined and `SKIP_BRIDGE` undefined (the same evaluation that hides the generic modifier variants behind `!SKIP`); `SKIP_BRIDGE` covers the two real bridge compiles (Android cross-compile, Robolectric host). `SKIP_BRIDGE` alone makes the generator emit `import SwiftUI` into the bridges, which then can't find `SkipUI.ViewModifier` on the macOS Robolectric host; `SKIP` alone breaks both real compiles. Note the generator *does* evaluate `#if` — an earlier `FormsKitSwiftUI` shim target existed on the belief that it couldn't, and was removed in `5b2f898`; don't reintroduce it. `ViewModifierTests.swift` is guarded with `!SKIP_BRIDGE` in addition to `!os(Android)` (in bridge builds FormsKit's views are SkipSwiftUI-typed, so real-SwiftUI hosting doesn't apply). One sharp edge: switching between `SKIP_ZERO` and Skip-active builds in the same checkout can leave stale incremental state (`missing required module 'CJNI'`) — run `swift package clean` when that appears. -- **Everything else public carries `// SKIP @nobridge`.** With `bridging: true`, skipstone tries to bridge the whole public API, and FormsKit's is unbridgeable by design: key paths (`ValidatedField`), generic types with constructors (`FormController`, `Validated`), and statics added via constrained extensions (the `.minLength(3)`-style rule factories) all hard-error in the generator. FormsKit is consumed from Swift only, so the Kotlin-facing surface is deliberately empty except the bridged modifier structs. A new public declaration gets `// SKIP @nobridge` unless it is a non-generic `View` or `ViewModifier` that must render on Android. -- **Property-wrapper storage in public SwiftUI types must be `internal`, not `private`.** Skip's bridge diagnostics reject private `@State`/`@Environment`/`@FocusState` storage inside bridged types ("Private state property cannot be bridged"). This is why `dismiss`, `showsDiscardWarning`, and `isFocused` are internal. -- **`ViewModifierTests.swift` is wrapped in `#if !os(Android)`.** It hosts views via `ImageRenderer`/`NS-`/`UIHostingController`, which don't exist on Android. Logic tests (rules, `Validated`, controller, focus) run on both platforms — keep new UI-hosting tests inside that guard and new logic tests outside it. -- **`FormController.swift` imports `SkipFuse` behind `#if canImport(SkipFuse)`.** On Android this wires `@Observable` change tracking into Compose; under `SKIP_ZERO` the module doesn't exist, hence the guard. Give any future `@Observable` type the same import. -- **The `SKIP_ZERO` block in `Package.swift` is the no-dependency escape hatch** for Apple-only consumers. Preserve it when touching the manifest, and keep the Skip dependencies out of any code path it can't strip. -- **`.formBindFocus(_:on:)` is degraded on Android** (SkipUI doesn't fully support optional-valued `@FocusState`); `.focused(on:equals:)` is the cross-platform-safe variant. Don't build new features on optional `@FocusState`. - ## Things to leave alone - The `Validated.State.editing` case. It's recorded on value changes but not (yet) read anywhere. Reserved for "field has been touched but not yet validated" UX. Don't remove it without a replacement. diff --git a/Package.resolved b/Package.resolved deleted file mode 100644 index 1aa8523..0000000 --- a/Package.resolved +++ /dev/null @@ -1,114 +0,0 @@ -{ - "originHash" : "00be0c89a5ce14089419b4292da34c97d8d1d84a9a76842a61fe1fb7ee29165a", - "pins" : [ - { - "identity" : "skip", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip.git", - "state" : { - "revision" : "885f0c520e1ebdbec1f0e296d713293dadc5a2f4", - "version" : "1.9.5" - } - }, - { - "identity" : "skip-android-bridge", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-android-bridge.git", - "state" : { - "revision" : "545ea1b2d7ba4abc82daa2be00f81f4ea8e64e5b", - "version" : "0.6.4" - } - }, - { - "identity" : "skip-bridge", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-bridge.git", - "state" : { - "revision" : "72b7b1d4734332cfdc4b519539b5beec0fb3ac00", - "version" : "0.17.2" - } - }, - { - "identity" : "skip-foundation", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-foundation.git", - "state" : { - "revision" : "94d47aeed3bb8027ef3ad8e07a8771b52529c238", - "version" : "1.4.2" - } - }, - { - "identity" : "skip-fuse", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-fuse.git", - "state" : { - "revision" : "8f3295094ad29075730284c5197c7f1d94c0f2d9", - "version" : "1.0.2" - } - }, - { - "identity" : "skip-fuse-ui", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-fuse-ui.git", - "state" : { - "revision" : "d27fc109268b21feb98a48bc1a3f4558e162d9ca", - "version" : "1.18.1" - } - }, - { - "identity" : "skip-lib", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-lib.git", - "state" : { - "revision" : "76e7da8a870b5b66ea0c3264f648b58b73bcdc0d", - "version" : "1.4.0" - } - }, - { - "identity" : "skip-model", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-model.git", - "state" : { - "revision" : "54c7914e985e5ae07b1a8fe29e7aac7156b88874", - "version" : "1.7.6" - } - }, - { - "identity" : "skip-ui", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-ui.git", - "state" : { - "revision" : "ef7bbdd541cdf2efd3ce6ecde72337e1beb92366", - "version" : "1.59.1" - } - }, - { - "identity" : "skip-unit", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/skip-unit.git", - "state" : { - "revision" : "c89af47fd645e04db863e938ade39f91e1bb62b8", - "version" : "1.7.0" - } - }, - { - "identity" : "swift-android-native", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/swift-android-native.git", - "state" : { - "revision" : "7e6e833e6f163a2b75340f75c70b1d96ea6b8135", - "version" : "1.5.1" - } - }, - { - "identity" : "swift-jni", - "kind" : "remoteSourceControl", - "location" : "https://source.skip.tools/swift-jni.git", - "state" : { - "revision" : "fe76ac21aca639976833b5ea3e875dc072519ac4", - "version" : "0.5.0" - } - } - ], - "version" : 3 -} diff --git a/Package.swift b/Package.swift index 5269450..6b7b798 100644 --- a/Package.swift +++ b/Package.swift @@ -19,68 +19,16 @@ let package = Package( targets: ["FormsKit"] ), ], - dependencies: [ - .package(url: "https://source.skip.tools/skip.git", from: "1.9.5"), - .package(url: "https://source.skip.tools/skip-fuse.git", from: "1.0.0"), - .package(url: "https://source.skip.tools/skip-fuse-ui.git", from: "1.0.0"), - ], targets: [ // Targets are the basic building blocks of a package, defining a module or a test suite. // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "FormsKit", - dependencies: [ - .product(name: "SkipFuse", package: "skip-fuse"), - // SkipFuseUI (not the SkipSwiftUI product): depending on the - // dynamic SkipSwiftUI product alongside SkipFuseUI's static use - // of the same target is a SwiftPM linkage conflict; the - // SkipSwiftUI *module* is importable transitively. - .product(name: "SkipFuseUI", package: "skip-fuse-ui"), - ], - plugins: [.plugin(name: "skipstone", package: "skip")] + name: "FormsKit" ), .testTarget( name: "FormsKitTests", - dependencies: [ - "FormsKit", - .product(name: "SkipTest", package: "skip"), - ], - plugins: [.plugin(name: "skipstone", package: "skip")] + dependencies: ["FormsKit"] ), ], swiftLanguageModes: [.v6] ) - -// Setting the SKIP_ZERO=1 environment strips out the Skip plugin and all Skip dependencies, -// restoring FormsKit to a zero-dependency package for consumers that don't target Android. -if Context.environment["SKIP_ZERO"] ?? "0" != "0" { - package.targets.forEach { target in - // remove the Skip plugin - target.plugins?.removeAll(where: { - if case .plugin(let name, _) = $0 { - return name == "skipstone" - } else { - return false - } - }) - - // remove the Skip target dependencies - target.dependencies.removeAll(where: { dependency in - if case .productItem(_, let package, _, _) = dependency { - return package == "skip" || package?.hasPrefix("skip-") == true - } else { - return false - } - }) - } - - // remove the Skip package dependencies - package.dependencies.removeAll(where: { dependency in - if case .sourceControl(_, let url, _) = dependency.kind { - return url.hasPrefix("https://source.skip.dev/") || url.hasPrefix("https://source.skip.tools/") - } else { - return false - } - }) -} diff --git a/README.md b/README.md index 0ff2e73..66c3929 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # FormsKit -[![Platforms](https://img.shields.io/badge/Platforms-iOS_|_macOS_|_watchOS_|_tvOS_|_visionOS_|_Android_(Skip)-blue.svg)](https://developer.apple.com/xcode/) +[![Platforms](https://img.shields.io/badge/Platforms-iOS_|_macOS_|_watchOS_|_tvOS_|_visionOS-blue.svg)](https://developer.apple.com/xcode/) [![Swift 6.3](https://img.shields.io/badge/Swift-6.3-orange.svg)](https://swift.org) [![Release](https://img.shields.io/github/v/release/rozd/forms-kit)](https://github.com/rozd/forms-kit/releases) [![codecov](https://codecov.io/gh/rozd/forms-kit/branch/main/graph/badge.svg)](https://codecov.io/gh/rozd/forms-kit) @@ -35,8 +35,7 @@ struct CreatePlanForm: ValidatableForm, SubmittableForm { - 🧰 **Built-in string rules** — `isNotEmpty`, `minLength`, `maxLength`, `pattern`, `email`. - 🎨 **SwiftUI modifiers** — `.formValidationError(for:)` for inline field errors, `.formToolbar(...)` for a Cancel/Submit toolbar, `.focused(on:equals:)` and `.formBindFocus(_:on:)` for focus traversal. - 🛡️ **Dirty-state-aware dismiss** — discard confirmation dialog + `interactiveDismissDisabled` when the form has unsaved changes. -- 🪶 **Zero runtime dependencies** — Foundation + SwiftUI + Observation. No Combine. The only external packages are [Skip](https://skip.dev)'s build-time integration for Android, and resolving with `SKIP_ZERO=1` strips even those (see [Android (Skip)](#android-skip)). -- 🤖 **Android via [Skip](https://skip.dev)** — compiles natively for Android as a Skip Fuse module; the same `@Validated` forms and modifiers drive Jetpack Compose through SkipFuseUI. +- 🪶 **Zero dependencies** — Foundation + SwiftUI + Observation. No Combine, no third-party packages. - ⚡ **`@Observable` native** — built for iOS 17+ / Swift 5.9+ macros, not `ObservableObject`. - 🔒 **Swift 6 concurrency** — explicit `@MainActor` isolation on the form lifecycle, no `Sendable` headaches for consumers. @@ -47,7 +46,6 @@ struct CreatePlanForm: ValidatableForm, SubmittableForm { - Swift 6.0+ (built with tools 6.3, language mode v6) - iOS 17 / macOS 14 / tvOS 17 / watchOS 10 / visionOS 1 - Xcode 16+ -- Android: via [Skip](https://skip.dev) 1.9.5+ (optional — see [Android (Skip)](#android-skip)) ## Installation @@ -66,31 +64,6 @@ targets: [ Or in Xcode: **File → Add Package Dependencies…** and paste the repository URL. -## Android (Skip) - -FormsKit is a [Skip](https://skip.dev) **Fuse (native) framework**: the Swift source — including the `@Validated` property wrapper and the `KeyPath`-driven focus system — is compiled natively for Android with the Swift SDK for Android, and the SwiftUI modifiers render through [SkipFuseUI](https://github.com/skiptools/skip-fuse-ui) → Jetpack Compose. (Skip's *transpiled* mode is not supported: its Swift-to-Kotlin transpiler handles neither custom property wrappers nor key paths, both of which are the heart of this library.) - -**In a Skip app**, add FormsKit as an ordinary dependency of your Fuse module — no extra configuration; Skip detects the module's `Skip/skip.yml` and wires the Gradle side automatically: - -```swift -.target(name: "MyApp", dependencies: [ - .product(name: "SkipFuseUI", package: "skip-fuse-ui"), - .product(name: "FormsKit", package: "forms-kit"), -], plugins: [.plugin(name: "skipstone", package: "skip")]) -``` - -**In an Apple-only project**, nothing changes at the call site, and the Skip packages are build-time-only (on Apple platforms `SkipFuseUI` simply re-exports SwiftUI and compiles away). If you don't want them in your dependency graph at all, resolve with the `SKIP_ZERO` environment variable set — the manifest then strips the Skip plugin and every Skip dependency, restoring a zero-dependency package: - -```bash -SKIP_ZERO=1 swift build -``` - -Platform notes: - -- The view modifiers are implemented as custom `ViewModifier`s with a twist: skip-bridge cannot represent generic types, so on Android the generic modifiers are swapped for non-generic, type-erased twins that skipstone bridges into Kotlin peers (an *unbridged* custom `ViewModifier` renders as a silent no-op on Android). `.formToolbar`, `.formValidationError`, and `.focused(on:equals:)` render identically on both platforms. -- `.formBindFocus(_:on:)` relies on an optional-valued `@FocusState`, which SkipUI does not fully support yet — prefer `.focused(on:equals:)` (internally `Bool`-based) in cross-platform forms. -- The view-modifier test suite runs on Apple platforms only (it hosts views via `ImageRenderer`/`HostingController`, which don't exist on Android); all validation, controller, and focus-logic tests run on both platforms. - --- ## Quick start @@ -595,7 +568,7 @@ FormsKit ships an **agent skill** at [`Skills/formskit-expert/`](Skills/formskit ## Roadmap - Localized default error messages via `String(localized:bundle: .module)`. -- Themeable error color on `formValidationError` (currently hardcoded `.red`). +- Themeable error color on `FormValidationErrorModifier` (currently hardcoded `.red`). - Localizable strings in `FormToolbarViewModifier` ("Discard Changes?", etc.). - Additional rule families (`Number`, `Date`, `Collection`). diff --git a/Skills/formskit-expert.skill b/Skills/formskit-expert.skill index 5fa0190..4eb6173 100644 Binary files a/Skills/formskit-expert.skill and b/Skills/formskit-expert.skill differ diff --git a/Skills/formskit-expert/references/api-cheatsheet.md b/Skills/formskit-expert/references/api-cheatsheet.md index 4454945..5c81005 100644 --- a/Skills/formskit-expert/references/api-cheatsheet.md +++ b/Skills/formskit-expert/references/api-cheatsheet.md @@ -147,9 +147,5 @@ Sources/FormsKit/ ├── Forms/{ValidatableForm,SubmittableForm,PopulatableForm}.swift ├── ValidationRules/StringValidationRule.swift ├── ValidationRules/StringValidationRules/*.swift -├── AnyFormController.swift # internal erased facade for the bridged (Android) modifier twins -└── ViewModifiers/{FormValidationErrorModifier,FormToolbarViewModifier,FocusedOnViewModifier,FormBindFocus}.swift +└── ViewModifiers/{FormValidationErrorModifier,FormToolbarViewModifier,FocusedOnViewModifier,FormBindFocusViewModifier}.swift ``` - -Single target. View files select their SwiftUI module inline — -`#if SKIP || SKIP_BRIDGE` → `import SkipSwiftUI`, else `import SwiftUI`. diff --git a/Sources/FormsKit/AnyFormController.swift b/Sources/FormsKit/AnyFormController.swift deleted file mode 100644 index 6c02e70..0000000 --- a/Sources/FormsKit/AnyFormController.swift +++ /dev/null @@ -1,31 +0,0 @@ -// Type-erased facade over `FormController` for the bridged (non-generic) -// view modifiers: skip-bridge cannot represent generic types, so the erased -// modifier variants store this instead of `FormController` and reach the -// controller through closures. Internal: consumers only ever see the generic -// `FormController` through the public `View` extension functions. -@MainActor -struct AnyFormController { - let isDirty: () -> Bool - let isLoading: () -> Bool - let validateReturningIsValid: () -> Bool - let getFocus: () -> AnyKeyPath? - let setFocus: (AnyKeyPath?) -> Void - - init(_ controller: FormController) { - self.isDirty = { controller.isDirty } - self.isLoading = { controller.isLoading } - self.validateReturningIsValid = { controller.validate(); return controller.form.isValid } - self.getFocus = { controller.focus } - self.setFocus = { controller.focus = $0.flatMap { $0 as? PartialKeyPath } } - } - - /// Focus-only erasure for `focused(on:equals:)`, whose form type carries no - /// validation constraints. The validation closures are inert stubs. - init(focusing controller: FormController) { - self.isDirty = { false } - self.isLoading = { false } - self.validateReturningIsValid = { false } - self.getFocus = { controller.focus } - self.setFocus = { controller.focus = $0.flatMap { $0 as? PartialKeyPath } } - } -} diff --git a/Sources/FormsKit/FormController.swift b/Sources/FormsKit/FormController.swift index 71a8acd..4f76a15 100644 --- a/Sources/FormsKit/FormController.swift +++ b/Sources/FormsKit/FormController.swift @@ -1,13 +1,5 @@ import Observation -#if canImport(SkipFuse) -// On Android (Skip Fuse), SkipFuse wires @Observable state tracking into -// Compose so views re-render when the controller changes. On Apple -// platforms the import is inert, and it disappears entirely when the -// package is resolved with SKIP_ZERO=1. -import SkipFuse -#endif - -// SKIP @nobridge + @MainActor @Observable public final class FormController { @@ -33,7 +25,6 @@ public final class FormController { // MARK: Controller Extension for Validatable Forms -// SKIP @nobridge extension FormController where T: ValidatableForm { var isDirty: Bool { @@ -66,7 +57,6 @@ extension FormController where T: ValidatableForm { // MARK: Controller Extension for Submittable and Validatable Forms -// SKIP @nobridge public extension FormController where T: SubmittableForm, T: ValidatableForm { var isLoading: Bool { diff --git a/Sources/FormsKit/Forms/PopulatableForm.swift b/Sources/FormsKit/Forms/PopulatableForm.swift index 5f306da..d65355f 100644 --- a/Sources/FormsKit/Forms/PopulatableForm.swift +++ b/Sources/FormsKit/Forms/PopulatableForm.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public protocol PopulatableForm { associatedtype Data diff --git a/Sources/FormsKit/Forms/SubmittableForm.swift b/Sources/FormsKit/Forms/SubmittableForm.swift index 21b9ccc..9a95978 100644 --- a/Sources/FormsKit/Forms/SubmittableForm.swift +++ b/Sources/FormsKit/Forms/SubmittableForm.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public protocol SubmittableForm { associatedtype Output diff --git a/Sources/FormsKit/Forms/ValidatableForm.swift b/Sources/FormsKit/Forms/ValidatableForm.swift index 10041fc..46aa4a4 100644 --- a/Sources/FormsKit/Forms/ValidatableForm.swift +++ b/Sources/FormsKit/Forms/ValidatableForm.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public protocol ValidatableForm { var validatedFields: [ValidatedField] { get } } diff --git a/Sources/FormsKit/Skip/skip.yml b/Sources/FormsKit/Skip/skip.yml deleted file mode 100644 index 08f60b5..0000000 --- a/Sources/FormsKit/Skip/skip.yml +++ /dev/null @@ -1,15 +0,0 @@ -# Skip (https://skip.dev) configuration for the FormsKit module. -# -# FormsKit is a natively-compiled Skip Fuse module: the Swift source is -# compiled as-is for Android with the Swift SDK for Android, so the -# @Validated property wrapper and the KeyPath-driven focus system work -# unchanged (neither is expressible in Skip's transpiled mode). -# -# Bridging is required: FormsKit's wrapper Views (the four form-control -# modifiers) need Kotlin peers so they render on Android. Custom Views bridge -# fine; module-wide bridging is safe. If the generator produces problematic -# Kotlin for other API, scope it down with `// SKIP @nobridge` on those -# declarations (never on the wrapper Views themselves). -skip: - mode: 'native' - bridging: true diff --git a/Sources/FormsKit/Validated.swift b/Sources/FormsKit/Validated.swift index 74ce59c..062b310 100644 --- a/Sources/FormsKit/Validated.swift +++ b/Sources/FormsKit/Validated.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge @propertyWrapper public struct Validated { diff --git a/Sources/FormsKit/ValidatedField.swift b/Sources/FormsKit/ValidatedField.swift index 18f7bd6..c41baa5 100644 --- a/Sources/FormsKit/ValidatedField.swift +++ b/Sources/FormsKit/ValidatedField.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public struct ValidatedField
{ public let keyPath: PartialKeyPath diff --git a/Sources/FormsKit/ValidationError.swift b/Sources/FormsKit/ValidationError.swift index a5191ea..038effb 100644 --- a/Sources/FormsKit/ValidationError.swift +++ b/Sources/FormsKit/ValidationError.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public enum ValidationError: Error { case misconfigured(message: String) case invalid(errors: [String: [String]]) diff --git a/Sources/FormsKit/ValidationRule.swift b/Sources/FormsKit/ValidationRule.swift index 75ee76a..26a6d95 100644 --- a/Sources/FormsKit/ValidationRule.swift +++ b/Sources/FormsKit/ValidationRule.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public protocol ValidationRule { associatedtype Value func validate(value: Value) -> String? diff --git a/Sources/FormsKit/ValidationRules/StringValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRule.swift index 155580d..8afe674 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRule.swift @@ -1,2 +1 @@ -// SKIP @nobridge public protocol StringValidationRule: ValidationRule where Value == String {} diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift index a5d790b..61779a9 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/EmailValidationRule.swift @@ -1,6 +1,5 @@ import Foundation -// SKIP @nobridge public struct EmailValidator: StringValidationRule { let message: String @@ -17,9 +16,6 @@ public struct EmailValidator: StringValidationRule { } } -// Kotlin companion objects cannot express static members added via -// generically-constrained extensions; these factories are Swift-only sugar. -// SKIP @nobridge public extension ValidationRule where Self == EmailValidator { static func email(message: String = "Invalid email address") -> EmailValidator { EmailValidator(message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift index fd1008b..a29cb13 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/MaxStringLengthValidationRule.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public struct MaxStringLengthValidationRule: StringValidationRule { let maxLength: Int let message: String @@ -13,9 +12,6 @@ public struct MaxStringLengthValidationRule: StringValidationRule { } } -// Kotlin companion objects cannot express static members added via -// generically-constrained extensions; these factories are Swift-only sugar. -// SKIP @nobridge public extension ValidationRule where Self == MaxStringLengthValidationRule { static func maxLength(_ length: Int, message: String? = nil) -> MaxStringLengthValidationRule { MaxStringLengthValidationRule(length, message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift index 3d02374..30bc46b 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/MinStringLengthValidationRule.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public struct MinStringLengthValidationRule: StringValidationRule { let minLength: Int let message: String @@ -13,9 +12,6 @@ public struct MinStringLengthValidationRule: StringValidationRule { } } -// Kotlin companion objects cannot express static members added via -// generically-constrained extensions; these factories are Swift-only sugar. -// SKIP @nobridge public extension ValidationRule where Self == MinStringLengthValidationRule { static func minLength(_ length: Int, message: String? = nil) -> MinStringLengthValidationRule { MinStringLengthValidationRule(length, message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift index 138e997..b52a4e4 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/NotEmptyStringRule.swift @@ -1,4 +1,3 @@ -// SKIP @nobridge public struct NotEmptyStringRule: StringValidationRule { let message: String @@ -10,9 +9,6 @@ public struct NotEmptyStringRule: StringValidationRule { } } -// Kotlin companion objects cannot express static members added via -// generically-constrained extensions; these factories are Swift-only sugar. -// SKIP @nobridge public extension ValidationRule where Self == NotEmptyStringRule { static func isNotEmpty(message: String) -> NotEmptyStringRule { NotEmptyStringRule(message: message) diff --git a/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift b/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift index 6dccb46..0b1e8e3 100644 --- a/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift +++ b/Sources/FormsKit/ValidationRules/StringValidationRules/RegularExpressionValidationRule.swift @@ -1,6 +1,5 @@ import Foundation -// SKIP @nobridge public struct RegularExpressionValidationRule: StringValidationRule { let pattern: String let message: String @@ -12,9 +11,6 @@ public struct RegularExpressionValidationRule: StringValidationRule { } } -// Kotlin companion objects cannot express static members added via -// generically-constrained extensions; these factories are Swift-only sugar. -// SKIP @nobridge public extension ValidationRule where Self == RegularExpressionValidationRule { static func pattern(_ pattern: String, message: String) -> RegularExpressionValidationRule { RegularExpressionValidationRule(pattern: pattern, message: message) diff --git a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift index eb7abc4..1e54322 100644 --- a/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FocusedOnViewModifier.swift @@ -1,67 +1,19 @@ -// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined -// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles -// (Android cross-compile, Robolectric host). Apple builds take the else branch. -#if SKIP || SKIP_BRIDGE -import SkipSwiftUI -#else import SwiftUI -#endif -// Dual structure: the fully-typed generic modifier serves non-bridge builds; -// the erased twin below serves bridge builds (skip-bridge cannot represent -// generic types). `!SKIP` additionally hides the generic variant from the -// skipstone generator, which parses with SKIP defined but SKIP_BRIDGE -// undefined and would otherwise try to bridge it. -#if !SKIP_BRIDGE && !SKIP public struct FocusedOnViewModifier: ViewModifier { - let controller: FormController - let fieldKeyPath: KeyPath - @FocusState var isFocused: Bool + let controller: Binding> - public func body(content: Content) -> some View { - content - .focused($isFocused) - .onChange(of: controller.focus) { _, new in - let shouldBeFocused = (new == fieldKeyPath) - guard isFocused != shouldBeFocused else { - return - } - Task { @MainActor in - isFocused = shouldBeFocused - } - } - .onChange(of: isFocused) { _, new in - if new { - if controller.focus != fieldKeyPath { - controller.focus = fieldKeyPath - } - } else if controller.focus == fieldKeyPath { - controller.focus = nil - } - } - } -} -#endif - -// Bridged twin for Android: non-generic (skip-bridge requirement), reaching -// the controller through `AnyFormController`'s closures and comparing focus -// identity as `AnyKeyPath`. Must NOT carry `// SKIP @nobridge` — the -// generated Kotlin peer is what makes the modifier apply on Android. Keep -// this body in sync with the generic variant above. -public struct ErasedFocusedOnModifier: ViewModifier { - let controller: AnyFormController - let fieldKeyPath: AnyKeyPath + let keyPath: KeyPath - // internal (not private): Skip's Android bridge for SwiftUI types - // cannot reach private property-wrapper storage. - @FocusState var isFocused: Bool + @FocusState private var isFocused: Bool public func body(content: Content) -> some View { - content + let myKeyPath: PartialKeyPath = keyPath + return content .focused($isFocused) - .onChange(of: controller.getFocus()) { _, new in - let shouldBeFocused = (new == fieldKeyPath) + .onChange(of: controller.wrappedValue.focus) { _, new in + let shouldBeFocused = (new == myKeyPath) guard isFocused != shouldBeFocused else { return } @@ -71,11 +23,11 @@ public struct ErasedFocusedOnModifier: ViewModifier { } .onChange(of: isFocused) { _, new in if new { - if controller.getFocus() != fieldKeyPath { - controller.setFocus(fieldKeyPath) + if controller.wrappedValue.focus != myKeyPath { + controller.wrappedValue.focus = myKeyPath } - } else if controller.getFocus() == fieldKeyPath { - controller.setFocus(nil) + } else if controller.wrappedValue.focus == myKeyPath { + controller.wrappedValue.focus = nil } } } @@ -83,23 +35,17 @@ public struct ErasedFocusedOnModifier: ViewModifier { // MARK: - View Extension -// SKIP @nobridge public extension View { func focused( on controller: Binding>, equals keyPath: KeyPath ) -> some View { - #if SKIP || SKIP_BRIDGE - return modifier(ErasedFocusedOnModifier( - controller: AnyFormController(focusing: controller.wrappedValue), - fieldKeyPath: keyPath - )) - #else - return modifier(FocusedOnViewModifier( - controller: controller.wrappedValue, - fieldKeyPath: keyPath - )) - #endif + modifier( + FocusedOnViewModifier( + controller: controller, + keyPath: keyPath + ) + ) } } diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift b/Sources/FormsKit/ViewModifiers/FormBindFocus.swift deleted file mode 100644 index 91b35c3..0000000 --- a/Sources/FormsKit/ViewModifiers/FormBindFocus.swift +++ /dev/null @@ -1,32 +0,0 @@ -// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined -// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles -// (Android cross-compile, Robolectric host). Apple builds take the else branch. -#if SKIP || SKIP_BRIDGE -import SkipSwiftUI -#else -import SwiftUI -#endif - -// MARK: - View Extension - -// SKIP @nobridge -public extension View { - - func formBindFocus( - _ focus: FocusState?>.Binding, - on controller: FormController - ) -> some View { - self - .onChange(of: focus.wrappedValue) { _, new in - if controller.focus != new { - controller.focus = new - } - } - .onChange(of: controller.focus) { _, new in - guard focus.wrappedValue != new else { return } - Task { @MainActor in - focus.wrappedValue = new - } - } - } -} diff --git a/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift new file mode 100644 index 0000000..36b9229 --- /dev/null +++ b/Sources/FormsKit/ViewModifiers/FormBindFocusViewModifier.swift @@ -0,0 +1,47 @@ +import SwiftUI + +public struct FormBindFocusViewModifier: ViewModifier { + + let focus: FocusState?>.Binding + + let controller: FormController + + public func body(content: Content) -> some View { + content + .onChange(of: focus.wrappedValue) { _, new in + Self.syncControllerFocus(controller, to: new) + } + .onChange(of: controller.focus) { _, new in + guard focus.wrappedValue != new else { return } + Task { @MainActor in + focus.wrappedValue = new + } + } + } + + static func syncControllerFocus( + _ controller: FormController, + to new: PartialKeyPath? + ) { + if controller.focus != new { + controller.focus = new + } + } +} + +// MARK: - View Extension + +public extension View { + + func formBindFocus( + _ focus: FocusState?>.Binding, + on controller: FormController + ) -> some View { + modifier( + FormBindFocusViewModifier( + focus: focus, + controller: controller + ) + ) + } +} diff --git a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift index 539adcf..feba1c7 100644 --- a/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormToolbarViewModifier.swift @@ -1,28 +1,19 @@ -// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined -// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles -// (Android cross-compile, Robolectric host). Apple builds take the else branch. -#if SKIP || SKIP_BRIDGE -import SkipSwiftUI -#else import SwiftUI -#endif -// Dual structure: the fully-typed generic modifier serves non-bridge builds; -// the erased twin below serves bridge builds (skip-bridge cannot represent -// generic types). `!SKIP` additionally hides the generic variant from the -// skipstone generator, which parses with SKIP defined but SKIP_BRIDGE -// undefined and would otherwise try to bridge it. -#if !SKIP_BRIDGE && !SKIP public struct FormToolbarViewModifier: ViewModifier { - @Environment(\.dismiss) var dismiss + @Environment(\.dismiss) private var dismiss - @State var showsDiscardWarning: Bool = false + @State private var showsDiscardWarning: Bool = false let controller: FormController + let cancelTitle: String + let submitTitle: String + let preventsAccidentalDismiss: Bool - let onSubmit: () -> Void + + let onSubmit: (() -> Void) public func body(content: Content) -> some View { content @@ -60,66 +51,9 @@ public struct FormToolbarViewModifier: Vie } } } -#endif - -// Bridged twin for Android: non-generic (skip-bridge requirement), reaching -// the controller through `AnyFormController`'s closures. Must NOT carry -// `// SKIP @nobridge` — the generated Kotlin peer is exactly what makes the -// modifier apply on Android (an unbridged custom ViewModifier falls back to -// SkipSwiftUI's default `Java_modifier`, an EmptyModifier, and silently -// renders nothing). Keep this body in sync with the generic variant above. -public struct ErasedFormToolbarModifier: ViewModifier { - // internal (not private): Skip's Android bridge for SwiftUI types - // cannot reach private property-wrapper storage. - @Environment(\.dismiss) var dismiss - - @State var showsDiscardWarning: Bool = false - - let controller: AnyFormController - let cancelTitle: String - let submitTitle: String - let preventsAccidentalDismiss: Bool - let onSubmit: () -> Void - - public func body(content: Content) -> some View { - content - .toolbar { - ToolbarItem(placement: .cancellationAction) { - Button(cancelTitle, action: cancelTapped) - } - ToolbarItem(placement: .confirmationAction) { - Button(submitTitle, action: submitTapped) - .bold() - .disabled(!controller.isDirty() || controller.isLoading()) - } - } - .interactiveDismissDisabled(preventsAccidentalDismiss && controller.isDirty()) - .confirmationDialog("Discard Changes?", isPresented: $showsDiscardWarning) { - Button("Discard Changes", role: .destructive) { dismiss() } - Button("Keep Editing", role: .cancel) { } - } message: { - Text("You have unsaved changes. Are you sure you want to discard them?") - } - } - - func cancelTapped() { - if preventsAccidentalDismiss && controller.isDirty() { - showsDiscardWarning = true - } else { - dismiss() - } - } - - func submitTapped() { - if controller.validateReturningIsValid() { - onSubmit() - } - } -} // MARK: - View Extension -// SKIP @nobridge public extension View { func formToolbar( @@ -127,24 +61,16 @@ public extension View { cancelTitle: String = "Cancel", submitTitle: String = "Submit", preventsAccidentalDismiss: Bool = true, - onSubmit: @escaping () -> Void + onSubmit: @escaping () -> Void, ) -> some View { - #if SKIP || SKIP_BRIDGE - return modifier(ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: cancelTitle, - submitTitle: submitTitle, - preventsAccidentalDismiss: preventsAccidentalDismiss, - onSubmit: onSubmit - )) - #else - return modifier(FormToolbarViewModifier( - controller: controller, - cancelTitle: cancelTitle, - submitTitle: submitTitle, - preventsAccidentalDismiss: preventsAccidentalDismiss, - onSubmit: onSubmit - )) - #endif + self.modifier( + FormToolbarViewModifier( + controller: controller, + cancelTitle: cancelTitle, + submitTitle: submitTitle, + preventsAccidentalDismiss: preventsAccidentalDismiss, + onSubmit: onSubmit + ) + ) } } diff --git a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift index 7b2ac3d..88431c7 100644 --- a/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift +++ b/Sources/FormsKit/ViewModifiers/FormValidationErrorModifier.swift @@ -1,26 +1,27 @@ -// `SKIP` covers the skipstone bridge generator (which parses with SKIP defined -// and SKIP_BRIDGE undefined); `SKIP_BRIDGE` covers the two real bridge compiles -// (Android cross-compile, Robolectric host). Apple builds take the else branch. -#if SKIP || SKIP_BRIDGE -import SkipSwiftUI -#else import SwiftUI -#endif - -// Non-generic on every platform (the generic `Validated.State` is unpacked -// into plain `[String]?` at the call site), so a single bridged modifier -// serves both. Must NOT carry `// SKIP @nobridge` — the generated Kotlin peer -// is what makes the modifier apply on Android. -public struct FormValidationErrorModifier: ViewModifier { - let errorMessages: [String]? + +public struct FormValidationErrorModifier: ViewModifier { + + let state: Validated.State + let alignment: HorizontalAlignment let spacing: CGFloat? + init( + state: Validated.State, + alignment: HorizontalAlignment = .leading, + spacing: CGFloat? = 4 + ) { + self.state = state + self.alignment = alignment + self.spacing = spacing + } + public func body(content: Content) -> some View { VStack(alignment: alignment, spacing: spacing) { content - if let errorMessages { - ForEach(errorMessages, id: \.self) { message in + if case let .invalid(messages) = state { + ForEach(messages, id: \.self) { message in Text(message) .foregroundStyle(.red) .font(.caption) @@ -32,7 +33,6 @@ public struct FormValidationErrorModifier: ViewModifier { // MARK: - View Extension -// SKIP @nobridge public extension View { func formValidationError( @@ -40,16 +40,12 @@ public extension View { alignment: HorizontalAlignment = .leading, spacing: CGFloat? = 4 ) -> some View { - let errorMessages: [String]? - if case let .invalid(messages) = state { - errorMessages = messages - } else { - errorMessages = nil - } - return modifier(FormValidationErrorModifier( - errorMessages: errorMessages, - alignment: alignment, - spacing: spacing - )) + modifier( + FormValidationErrorModifier( + state: state, + alignment: alignment, + spacing: spacing + ) + ) } } diff --git a/Tests/FormsKitTests/Skip/skip.yml b/Tests/FormsKitTests/Skip/skip.yml deleted file mode 100644 index 1ef653f..0000000 --- a/Tests/FormsKitTests/Skip/skip.yml +++ /dev/null @@ -1,3 +0,0 @@ -# Skip (https://skip.dev) configuration for the FormsKitTests module. -skip: - mode: 'native' diff --git a/Tests/FormsKitTests/ViewModifierTests.swift b/Tests/FormsKitTests/ViewModifierTests.swift index b544fc8..a409884 100644 --- a/Tests/FormsKitTests/ViewModifierTests.swift +++ b/Tests/FormsKitTests/ViewModifierTests.swift @@ -1,12 +1,3 @@ -// These tests drive the modifiers through real SwiftUI hosts -// (ImageRenderer / NS-/UIHostingController), which don't exist on Android — -// and in bridge builds (SKIP_BRIDGE: the Android cross-compile and the -// Robolectric host build) FormsKit's views are SkipSwiftUI-typed, so real -// SwiftUI hosting doesn't apply there either. On Android (Skip Fuse) the -// modifiers are exercised by consumers' own UI tests instead; the library's -// logic tests all run on both platforms. -#if !os(Android) && !SKIP_BRIDGE - import Testing import SwiftUI @testable import FormsKit @@ -19,7 +10,7 @@ import UIKit // MARK: - Fixtures -struct VMForm: ValidatableForm, SubmittableForm { +private struct VMForm: ValidatableForm, SubmittableForm { @Validated(name: "name", .isNotEmpty(message: "Required")) var name: String = "" @@ -35,10 +26,10 @@ struct VMForm: ValidatableForm, SubmittableForm { func submit() async throws -> String { name } } -// MARK: - formValidationError +// MARK: - FormValidationErrorModifier @MainActor -@Suite("formValidationError") +@Suite("FormValidationErrorModifier") struct FormValidationErrorModifierTests { @Test("View extension `.formValidationError(for:)` builds a modified view — .idle branch") @@ -141,7 +132,7 @@ struct FormToolbarViewModifierTests { @Test("cancelTapped on a clean form invokes dismiss (no warning shown)") func cancelTappedCleanDismisses() { let controller = FormController(form: VMForm()) - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -157,7 +148,7 @@ struct FormToolbarViewModifierTests { func cancelTappedDirtyShowsWarning() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -174,7 +165,7 @@ struct FormToolbarViewModifierTests { func cancelTappedNoPreventDismissesWhenDirty() { let controller = FormController(form: VMForm()) controller.form.name = "edited" - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -193,7 +184,7 @@ struct FormToolbarViewModifierTests { controller.form.name = "Alice" controller.form.email = "alice@example.com" var didSubmit = false - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -210,7 +201,7 @@ struct FormToolbarViewModifierTests { let controller = FormController(form: VMForm()) // Both fields empty → invalid after validate(). var didSubmit = false - let modifier = FormToolbarViewModifier( + let modifier = FormToolbarViewModifier( controller: controller, cancelTitle: "Cancel", submitTitle: "Submit", @@ -222,172 +213,12 @@ struct FormToolbarViewModifierTests { // validate() ran — both fields are now in .invalid state. #expect(controller.form.isValid == false) } - - // MARK: ErasedFormToolbarModifier (the variant Android actually runs) - - @Test("Erased twin: cancelTapped on a clean form invokes dismiss") - func erasedCancelTappedCleanDismisses() { - let controller = FormController(form: VMForm()) - let modifier = ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: true, - onSubmit: { Issue.record("onSubmit should not fire for cancel") } - ) - modifier.cancelTapped() - } - - @Test("Erased twin: cancelTapped on a dirty form takes the warning branch") - func erasedCancelTappedDirtyShowsWarning() { - let controller = FormController(form: VMForm()) - controller.form.name = "edited" - let modifier = ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: true, - onSubmit: { Issue.record("onSubmit should not fire for cancel") } - ) - modifier.cancelTapped() - } - - @Test("Erased twin: submitTapped with valid form runs validate() then onSubmit") - func erasedSubmitTappedValidCallsOnSubmit() { - let controller = FormController(form: VMForm()) - controller.form.name = "Alice" - controller.form.email = "alice@example.com" - var didSubmit = false - let modifier = ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: true, - onSubmit: { didSubmit = true } - ) - modifier.submitTapped() - #expect(didSubmit == true) - #expect(controller.form.isValid == true) - } - - @Test("Erased twin: submitTapped with invalid form runs validate() but skips onSubmit") - func erasedSubmitTappedInvalidSkipsOnSubmit() { - let controller = FormController(form: VMForm()) - var didSubmit = false - let modifier = ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: true, - onSubmit: { didSubmit = true } - ) - modifier.submitTapped() - #expect(didSubmit == false) - #expect(controller.form.isValid == false) - } - - @Test("Erased twin: cancelTapped with preventsAccidentalDismiss=false dismisses even when dirty") - func erasedCancelTappedNoPreventDismissesWhenDirty() { - let controller = FormController(form: VMForm()) - controller.form.name = "edited" - let modifier = ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: false, - onSubmit: { Issue.record("onSubmit should not fire for cancel") } - ) - modifier.cancelTapped() - } - - @Test("Erased twin: body renders for a dirty form (submit button disabled path)") - func erasedBodyRendersDirty() { - let controller = FormController(form: VMForm()) - controller.form.name = "edited" - let view = NavigationStack { - Text("body").modifier(ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: true, - onSubmit: { } - )) - } - _renderOnce(view) - } - - @Test("Erased twin: body renders for a clean form") - func erasedBodyRendersClean() { - let controller = FormController(form: VMForm()) - let view = NavigationStack { - Text("body").modifier(ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Close", - submitTitle: "Create", - preventsAccidentalDismiss: false, - onSubmit: { } - )) - } - _renderOnce(view) - } - - @Test("Erased twin: hosting the toolbar drives the @State property initializer") - func erasedBodyHosted() async { - // As with the generic variant: `ImageRenderer` doesn't always install - // `@State` containers, an `NSHostingController` does — so this is what - // fires `showsDiscardWarning`'s default initializer on the erased twin. - let controller = FormController(form: VMForm()) - controller.form.name = "edited" - let view = NavigationStack { - Text("body").modifier(ErasedFormToolbarModifier( - controller: AnyFormController(controller), - cancelTitle: "Cancel", - submitTitle: "Submit", - preventsAccidentalDismiss: true, - onSubmit: { } - )) - } - await _withHostedView(view) { /* nothing to mutate */ } - } - - @Test("AnyFormController(_:) forwards isDirty / isLoading / validate to the controller") - func anyFormControllerFullErasure() { - let controller = FormController(form: VMForm()) - let erased = AnyFormController(controller) - #expect(erased.isDirty() == false) - #expect(erased.isLoading() == false) - controller.form.name = "edited" - #expect(erased.isDirty() == true) - // Still invalid — `email` is empty. - #expect(erased.validateReturningIsValid() == false) - controller.form.email = "alice@example.com" - #expect(erased.validateReturningIsValid() == true) - // Focus round-trips through the same facade the toolbar erasure builds. - erased.setFocus(\VMForm.email) - #expect(controller.focus == \VMForm.email) - #expect(erased.getFocus() == \VMForm.email) - } - - @Test("AnyFormController(focusing:) reads and writes controller.focus; validation stubs are inert") - func anyFormControllerFocusingErasure() { - let controller = FormController(form: VMForm()) - let erased = AnyFormController(focusing: controller) - #expect(erased.getFocus() == nil) - erased.setFocus(\VMForm.name) - #expect(controller.focus == \VMForm.name) - #expect(erased.getFocus() == \VMForm.name) - erased.setFocus(nil) - #expect(controller.focus == nil) - #expect(erased.validateReturningIsValid() == false) - #expect(erased.isDirty() == false) - #expect(erased.isLoading() == false) - } } // MARK: - FormBindFocusViewModifier /// Hosts the modifier under a real SwiftUI runtime so its `onChange` handlers fire. -struct FormBindFocusHostView: View { +private struct FormBindFocusHostView: View { let controller: FormController @FocusState var focus: PartialKeyPath? @@ -401,7 +232,7 @@ struct FormBindFocusHostView: View { /// `onChange(of: focus.wrappedValue)` handler (focus → controller direction) /// gets exercised. The fields are real `TextField`s bound to the same /// `@FocusState` so SwiftUI can actually grant focus on the write. -struct FormBindFocusAppearHost: View { +private struct FormBindFocusAppearHost: View { let controller: FormController @FocusState var focus: PartialKeyPath? let appearAction: (FocusState?>.Binding) -> Void @@ -424,7 +255,7 @@ struct FormBindFocusAppearHost: View { } @MainActor -@Suite("formBindFocus", .serialized) +@Suite("FormBindFocusViewModifier", .serialized) struct FormBindFocusViewModifierTests { @Test("View extension `.formBindFocus(_:on:)` builds a modified view without crashing") @@ -478,13 +309,37 @@ struct FormBindFocusViewModifierTests { // SwiftUI focus-system assertion, not a FormsKit one. } + // MARK: syncControllerFocus (deterministic) + + @Test("syncControllerFocus writes a new value into controller.focus") + func syncControllerFocusWritesNewValue() { + let controller = FormController(form: VMForm()) + FormBindFocusViewModifier.syncControllerFocus(controller, to: \VMForm.name) + #expect(controller.focus == \VMForm.name) + } + + @Test("syncControllerFocus is a no-op when the controller is already there") + func syncControllerFocusNoOpWhenEqual() { + let controller = FormController(form: VMForm()) + controller.focus = \VMForm.email + FormBindFocusViewModifier.syncControllerFocus(controller, to: \VMForm.email) + #expect(controller.focus == \VMForm.email) + } + + @Test("syncControllerFocus clears controller.focus when handed nil") + func syncControllerFocusClears() { + let controller = FormController(form: VMForm()) + controller.focus = \VMForm.email + FormBindFocusViewModifier.syncControllerFocus(controller, to: nil) + #expect(controller.focus == nil) + } } // MARK: - FocusedOnViewModifier /// `.focused(on:equals:)` owns its `@FocusState` internally and takes a /// `Binding>`; the host materialises that binding via `@State`. -struct FocusedOnHostView: View { +private struct FocusedOnHostView: View { @State var controller: FormController var body: some View { @@ -500,7 +355,7 @@ struct FocusedOnHostView: View { /// inside `onAppear` causes SwiftUI to grant focus to the chosen field, /// which flips the modifier's internal `@FocusState` and exercises the /// `onChange(of: isFocused)` handler. -struct FocusedOnFocusableHost: View { +private struct FocusedOnFocusableHost: View { enum Scenario { case setInitial(PartialKeyPath) case setThenClear(PartialKeyPath) @@ -544,81 +399,8 @@ struct FocusedOnFocusableHost: View { } } -/// Mirror of `FocusedOnHostView` for the erased twin. The public -/// `.focused(on:equals:)` extension only routes to `ErasedFocusedOnModifier` -/// under `SKIP_BRIDGE`, so on Apple builds the twin has to be applied by hand -/// via `.modifier(_:)` — which is also exactly what skipstone's generated -/// Kotlin peer ends up doing on Android. -struct ErasedFocusedOnHostView: View { - let controller: FormController - - var body: some View { - VStack { - Text("name field").modifier(ErasedFocusedOnModifier( - controller: AnyFormController(focusing: controller), - fieldKeyPath: \VMForm.name - )) - Text("email field").modifier(ErasedFocusedOnModifier( - controller: AnyFormController(focusing: controller), - fieldKeyPath: \VMForm.email - )) - } - } -} - -/// Erased mirror of `FocusedOnFocusableHost`: real `TextField`s plus a parent -/// `@FocusState` driver, so SwiftUI actually grants focus and the twin's -/// `onChange(of: isFocused)` handler fires. -struct ErasedFocusedOnFocusableHost: View { - enum Scenario { - case setInitial(PartialKeyPath) - case setThenClear(PartialKeyPath) - case setThenSwitch(PartialKeyPath, PartialKeyPath) - } - - @FocusState var parentFocus: PartialKeyPath? - @State var controller: FormController - let scenario: Scenario - - var body: some View { - VStack { - TextField("name", text: $controller.form.name) - .focused($parentFocus, equals: \VMForm.name) - .modifier(ErasedFocusedOnModifier( - controller: AnyFormController(focusing: controller), - fieldKeyPath: \VMForm.name - )) - TextField("email", text: $controller.form.email) - .focused($parentFocus, equals: \VMForm.email) - .modifier(ErasedFocusedOnModifier( - controller: AnyFormController(focusing: controller), - fieldKeyPath: \VMForm.email - )) - } - .onAppear { - // Same deferral rationale as `FocusedOnFocusableHost`: writes made - // synchronously in `onAppear` race the focus system's wire-up. - Task { @MainActor in - try? await Task.sleep(nanoseconds: 20_000_000) - switch scenario { - case .setInitial(let kp): - parentFocus = kp - case .setThenClear(let kp): - parentFocus = kp - try? await Task.sleep(nanoseconds: 30_000_000) - parentFocus = nil - case .setThenSwitch(let a, let b): - parentFocus = a - try? await Task.sleep(nanoseconds: 30_000_000) - parentFocus = b - } - } - } - } -} - @MainActor -@Suite("focused(on:equals:)", .serialized) +@Suite("FocusedOnViewModifier", .serialized) struct FocusedOnViewModifierTests { @Test("View extension `.focused(on:equals:)` builds a modified view without crashing") @@ -685,78 +467,6 @@ struct FocusedOnViewModifierTests { await _withHostedView(host) { /* mutations happen via deferred Task */ } #expect(controller.focus == nil) } - - // MARK: ErasedFocusedOnModifier (the variant Android actually runs) - // - // These live in the `focused(on:equals:)` suite rather than a suite of - // their own on purpose: SwiftUI's focus system is process-global (one key - // window), and `.serialized` only orders tests *within* a suite — sibling - // top-level suites still run concurrently, so a second window-hosting - // focus suite steals first responder from this one and both flake. - - @Test("Erased twin builds a modified view without crashing") - func erasedFocusedOnBuilds() { - _renderOnce(ErasedFocusedOnHostView(controller: FormController(form: VMForm()))) - } - - @Test("Erased twin: setting controller.focus to this field's key path drives the sync handler") - func erasedControllerFocusTriggersHandler() async { - let controller = FormController(form: VMForm()) - let host = ErasedFocusedOnHostView(controller: controller) - await _withHostedView(host) { - controller.focus = \VMForm.name - } - } - - @Test("Erased twin: setting controller.focus elsewhere triggers the not-mine branch") - func erasedControllerFocusOtherKeyPath() async { - let controller = FormController(form: VMForm()) - controller.focus = \VMForm.name - let host = ErasedFocusedOnHostView(controller: controller) - await _withHostedView(host) { - controller.focus = \VMForm.email - controller.focus = nil - } - } - - @Test("Erased twin: SwiftUI focus on a TextField exercises the focus → controller handler") - func erasedSwiftUIFocusPropagatesToController() async { - let controller = FormController(form: VMForm()) - let host = ErasedFocusedOnFocusableHost( - controller: controller, - scenario: .setInitial(\VMForm.name) - ) - await _withHostedView(host) { /* mutation happens via deferred Task */ } - // No strict assertion — same reasoning as the generic variant's - // `swiftUIFocusPropagatesToController`: whether the first programmatic - // focus write lands in a non-key window varies; the setThenSwitch and - // setThenClear scenarios below carry the assertions. - } - - @Test("Erased twin: switching SwiftUI focus writes the new key path through setFocus") - func erasedSwiftUIFocusSwitchUpdatesController() async { - let controller = FormController(form: VMForm()) - let host = ErasedFocusedOnFocusableHost( - controller: controller, - scenario: .setThenSwitch(\VMForm.name, \VMForm.email) - ) - await _withHostedView(host) { /* mutations happen via deferred Task */ } - // Proves the AnyKeyPath → PartialKeyPath downcast inside - // `AnyFormController.setFocus` round-trips to a value that still - // compares equal to the literal `\VMForm.email` a consumer writes. - #expect(controller.focus == \VMForm.email) - } - - @Test("Erased twin: clearing SwiftUI focus clears controller.focus (else-if branch)") - func erasedSwiftUIUnfocusClearsController() async { - let controller = FormController(form: VMForm()) - let host = ErasedFocusedOnFocusableHost( - controller: controller, - scenario: .setThenClear(\VMForm.name) - ) - await _withHostedView(host) { /* mutations happen via deferred Task */ } - #expect(controller.focus == nil) - } } // MARK: - Helpers @@ -825,5 +535,3 @@ private func _spin() async { await Task.yield() } } - -#endif