328 lines
10 KiB
Markdown
328 lines
10 KiB
Markdown
# Components
|
|
|
|
How a Tiledown-style native editor builds its UI from reusable, registry-driven components. This file consolidates the component protocol, the package hierarchy, the registry, and the one-component-per-file discipline.
|
|
|
|
## One component per file
|
|
|
|
- Each UI component lives in its own component target, NEVER in feature targets or screens.
|
|
- ONE component per file; the file name equals the component name.
|
|
- Every component conforms to the `Component` protocol from the `Components` target.
|
|
|
|
## Core rules
|
|
|
|
### Rule 1: Three-target hierarchy
|
|
|
|
Use this strict layering:
|
|
|
|
```
|
|
Components (core infrastructure - ZERO dependencies)
|
|
↓
|
|
SharedComponents (hot reload - depends on hot-reload + file-watch tools only)
|
|
↓
|
|
AppComponents (app-specific - depends on Components + theme + font packages)
|
|
↓
|
|
AllComponents (optional aggregator for previews - depends on Components + AppComponents)
|
|
```
|
|
|
|
### Rule 2: Components target foundation
|
|
|
|
Create the `Components` target FIRST with the core infrastructure:
|
|
|
|
- MUST contain: the `Component` protocol, `AnyComponent`, `ComponentsRegistry`, `ComponentFactory`.
|
|
- MUST contain: bundle management, registration interfaces, and the component-list model/view.
|
|
- MUST include a `components.json` configuration file, processed with `.process()`.
|
|
- MUST have ZERO dependencies (foundation layer).
|
|
|
|
### Rule 3: SharedComponents dependencies
|
|
|
|
Depend ONLY on hot-reload tooling:
|
|
|
|
- A hot-reload injection library and a file-watching library.
|
|
- NEVER depend on UI targets (theme, font). Mixing them in breaks the hot-reload layer's independence from the UI foundation.
|
|
|
|
### Rule 4: AppComponents dependencies
|
|
|
|
Depend on the foundation targets:
|
|
|
|
- `Components` (core system)
|
|
- The theme package (colors, styles)
|
|
- The font package (typography)
|
|
|
|
### Rule 5: Component registration
|
|
|
|
Register components via the registry:
|
|
|
|
- All components conform to the `Component` protocol from the `Components` target.
|
|
- Registration happens in a per-target `*ComponentsRegistration.swift`.
|
|
- Register external components through the shared registry.
|
|
|
|
## The Component protocol
|
|
|
|
```swift
|
|
import Foundation
|
|
import SwiftUI
|
|
|
|
public protocol ComponentData: Hashable, Decodable, Sendable {}
|
|
public typealias ComponentKind = String
|
|
|
|
public protocol Component: Sendable {
|
|
associatedtype Data: ComponentData
|
|
associatedtype ViewBody: View
|
|
|
|
var data: Data { get }
|
|
|
|
/// Build the SwiftUI view for this component.
|
|
/// Runs on the main actor because SwiftUI view construction
|
|
/// and state are main-actor isolated.
|
|
@MainActor
|
|
func make() -> ViewBody
|
|
|
|
static var kind: ComponentKind { get }
|
|
init(data: Data)
|
|
}
|
|
|
|
public extension Component {
|
|
/// Auto-generates kind from the type name (e.g. "TilePreviewComponent" → "tilepreview").
|
|
@inline(__always)
|
|
static var kind: ComponentKind {
|
|
let fullName = String(reflecting: Self.self)
|
|
let parts = fullName.split(separator: ".")
|
|
let typePath = parts.dropFirst().joined(separator: ".")
|
|
return typePath
|
|
.replacingOccurrences(of: "Component", with: "")
|
|
.lowercased()
|
|
}
|
|
|
|
/// Register this component in the registry.
|
|
static func register(in registry: ComponentsRegistry) {
|
|
let componentType = Self.self
|
|
let dataType = Self.Data.self
|
|
|
|
registry.decoders[kind] = { decoder in
|
|
let data = try decoder.decode(dataType, forKey: .payload)
|
|
return ComponentFactory(data: data, type: componentType)
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
### AnyComponent (type-erased wrapper)
|
|
|
|
`AnyComponent` is a type-erased wrapper struct, not a protocol. Concrete components conform to `Component`; `AnyComponent` wraps any of them for storage and rendering.
|
|
|
|
```swift
|
|
@MainActor
|
|
public struct AnyComponent: Identifiable {
|
|
private let component: any Component
|
|
public var id: UUID = .init()
|
|
public let kind: ComponentKind
|
|
|
|
private let _make: () -> AnyView
|
|
|
|
public init<ComponentType>(_ component: ComponentType) where ComponentType: Component {
|
|
self.component = component
|
|
kind = ComponentType.kind
|
|
_make = { AnyView(component.make()) }
|
|
}
|
|
|
|
public var contentView: AnyView { _make() }
|
|
}
|
|
```
|
|
|
|
### ComponentFactory (Sendable bridge)
|
|
|
|
`ComponentFactory` is `Sendable` so component data can be decoded off the main thread, then rendered on the main actor.
|
|
|
|
```swift
|
|
public struct ComponentFactory: Sendable {
|
|
public let kind: ComponentKind
|
|
|
|
private let _makeRenderable: @MainActor () -> AnyComponent
|
|
|
|
public init<C: Component>(data: C.Data, type: C.Type) {
|
|
kind = C.kind
|
|
_makeRenderable = {
|
|
let instance = C(data: data)
|
|
return AnyComponent(instance)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
public func makeRenderable() -> AnyComponent {
|
|
_makeRenderable()
|
|
}
|
|
}
|
|
```
|
|
|
|
### ComponentsRegistry
|
|
|
|
The registry maps a component `kind` to a decoder that produces a `ComponentFactory`. JSON describing a component carries a `kind` plus a `payload`, and the registry decodes the right concrete data type.
|
|
|
|
```swift
|
|
public final class ComponentsRegistry {
|
|
public typealias ComponentDecoder =
|
|
@Sendable (KeyedDecodingContainer<Container.CodingKeys>) throws -> ComponentFactory
|
|
|
|
public var decoders: [ComponentKind: ComponentDecoder] = [:]
|
|
|
|
public struct Container: Decodable {
|
|
public enum CodingKeys: CodingKey { case kind, payload }
|
|
public struct ComponentNotFound: Error { public var kind: ComponentKind }
|
|
public struct DecodersNotFound: Error {}
|
|
|
|
public static let decodersKey = CodingUserInfoKey(rawValue: "ComponentDecoders")!
|
|
public let factory: ComponentFactory
|
|
|
|
public init(from decoder: Decoder) throws {
|
|
guard let decoders = decoder.userInfo[Self.decodersKey] as? [ComponentKind: ComponentDecoder] else {
|
|
throw DecodersNotFound()
|
|
}
|
|
let container = try decoder.container(keyedBy: CodingKeys.self)
|
|
let kind = try container.decode(ComponentKind.self, forKey: .kind)
|
|
guard let decodeForKind = decoders[kind] else {
|
|
throw ComponentNotFound(kind: kind)
|
|
}
|
|
factory = try decodeForKind(container)
|
|
}
|
|
}
|
|
|
|
public init() {}
|
|
|
|
/// Decode JSON into ComponentFactories (safe off the main thread).
|
|
public func decodeFactories(from data: Data) -> [ComponentFactory] {
|
|
do {
|
|
let decoder = JSONDecoder()
|
|
decoder.userInfo[Container.decodersKey] = decoders
|
|
let containers = try decoder.decode([Container].self, from: data)
|
|
return containers.map(\.factory)
|
|
} catch {
|
|
print("Unable to parse \(error)")
|
|
return []
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
## Concrete component example
|
|
|
|
One component per file. Define the data struct, store it, implement `make()`, and provide a preview.
|
|
|
|
```swift
|
|
import Components
|
|
import SwiftUI
|
|
|
|
public struct TilePreviewComponent: Component {
|
|
public struct Data: ComponentData {
|
|
public var title: String
|
|
public var subtitle: String
|
|
public var isActive: Bool = true
|
|
|
|
public init(title: String, subtitle: String, isActive: Bool = true) {
|
|
self.title = title
|
|
self.subtitle = subtitle
|
|
self.isActive = isActive
|
|
}
|
|
}
|
|
|
|
public var data: Data
|
|
|
|
public init(data: Data) {
|
|
self.data = data
|
|
}
|
|
|
|
public func make() -> some View {
|
|
TilePreviewContent(data: data)
|
|
}
|
|
}
|
|
|
|
// Separate view struct for the actual UI.
|
|
struct TilePreviewContent: View {
|
|
var data: TilePreviewComponent.Data
|
|
@Environment(\.appTheme) private var theme
|
|
|
|
var body: some View {
|
|
// card implementation using theme.colors and the font package
|
|
EmptyView()
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
TilePreviewComponent(data: .init(
|
|
title: "Hero Tile",
|
|
subtitle: "Top of the canvas"
|
|
)).make()
|
|
}
|
|
```
|
|
|
|
## components.json
|
|
|
|
Describes the components and sample payloads used by previews and the component gallery. Lives in the `Components` package, processed with `.process()`.
|
|
|
|
```json
|
|
{
|
|
"components": [
|
|
{
|
|
"kind": "tile-preview",
|
|
"category": "Display",
|
|
"description": "Displays a tile preview with status",
|
|
"payload": {
|
|
"title": "Hero Tile",
|
|
"subtitle": "Top of the canvas",
|
|
"isActive": true
|
|
}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
## Registration
|
|
|
|
```swift
|
|
import Components
|
|
|
|
/// Register a package's components in the registry.
|
|
public func registerTileComponents(in registry: ComponentsRegistry) {
|
|
TilePreviewComponent.register(in: registry)
|
|
// additional components...
|
|
}
|
|
|
|
// In app initialization:
|
|
let registry = ComponentsRegistry()
|
|
registerTileComponents(in: registry)
|
|
```
|
|
|
|
## AllComponents aggregator
|
|
|
|
An optional umbrella package that re-exports `Components` and `AppComponents` for convenient imports.
|
|
|
|
```swift
|
|
@_exported import Components
|
|
@_exported import AppComponents
|
|
```
|
|
|
|
- USE only in preview/demo apps and the component gallery.
|
|
- DO NOT use in production features or main app targets; be explicit with imports there.
|
|
|
|
## Common mistakes
|
|
|
|
- Creating `AppComponents` without the core `Components` package.
|
|
- Adding UI dependencies (theme, font) to `Components` (it MUST have zero dependencies).
|
|
- Adding UI dependencies to `SharedComponents` (hot reload only).
|
|
- Importing the broad `AllComponents` aggregator inside production features.
|
|
- Over-specific package naming. Keep app-specific components in `AppComponents`, not a narrowly named package.
|
|
|
|
## Checklist
|
|
|
|
- [ ] `Components` package exists with zero dependencies, created first
|
|
- [ ] `Components` contains the protocol, `AnyComponent`, registry, factory, list types
|
|
- [ ] `Components` includes `components.json`, processed with `.process()`
|
|
- [ ] `SharedComponents` depends ONLY on hot-reload + file-watch tooling
|
|
- [ ] `AppComponents` depends on Components + theme + font
|
|
- [ ] `AppComponents` holds production components only
|
|
- [ ] `AllComponents`, if present, used only in preview/demo apps
|
|
- [ ] All components conform to the `Component` protocol
|
|
- [ ] ONE component per file; file name equals component name
|
|
- [ ] Registration goes through the shared registry
|
|
- [ ] Resources use `.process()` not `.copy()`
|
|
- [ ] The component layer follows the strict hierarchy
|