Skip to content

Commit 4f16c10

Browse files
authored
basic DOM element bindings (#23)
* added binding equating logic * wip bindings * fixed embedded build * a bit of clean up * added number and checked bindings * setting undefined behaves better * binding examples * fix build * fail slow
1 parent 9660801 commit 4f16c10

File tree

18 files changed

+765
-67
lines changed

18 files changed

+765
-67
lines changed

.github/workflows/ci.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ jobs:
1717
runs-on: ubuntu-latest
1818
timeout-minutes: 15
1919
strategy:
20+
fail-fast: false
2021
matrix:
2122
image: ["swift:6.1", "swiftlang/swift:nightly-6.2-noble"]
2223

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import ElementaryDOM
2+
3+
@View
4+
struct BindingsView {
5+
@State var number: Double?
6+
@State var checked: Bool = false
7+
@State var text: String = ""
8+
9+
var content: some View {
10+
div {
11+
p {
12+
"Text: "
13+
input(.type(.text))
14+
.bindValue($text)
15+
span { " - \(text)" }
16+
}
17+
18+
p {
19+
"Number: "
20+
input(.type(.number)).bindValue($number)
21+
22+
span { " - \(number.map { "\($0)" } ?? "nil")" }
23+
}
24+
25+
p {
26+
"Checked: "
27+
input(.type(.checkbox))
28+
.bindChecked($checked)
29+
span { " - \(checked)" }
30+
}
31+
32+
button { "Set values" }
33+
.onClick { _ in
34+
number = 42
35+
checked = true
36+
text = "Hello"
37+
}
38+
}
39+
40+
// select {
41+
// option { "Option 1" }
42+
// option { "Option 2" }
43+
// option { "Option 3" }
44+
// }
45+
}
46+
}

Examples/Basic/Sources/App/Views.swift

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -11,17 +11,20 @@ struct App {
1111
@State var data = SomeData()
1212

1313
var content: some View {
14-
TextField(value: Binding(get: { data.name }, set: { data.name = $0 }))
1514
div {
16-
p { "Via Binding: \(data.name)" }
17-
p { TestValueView() }
18-
p { TestObjectView() }
19-
}
20-
.environment(#Key(\.myText), data.name)
21-
.environment(data)
15+
TextField(value: #Binding(data.name))
2216

17+
div {
18+
p { "Via Binding: \(data.name)" }
19+
p { TestValueView() }
20+
p { TestObjectView() }
21+
}
22+
.environment(#Key(\.myText), data.name)
23+
.environment(data)
24+
}
25+
hr()
26+
BindingsView()
2327
hr()
24-
2528
// TODE: replaceChildren does not keep animations and similar going....
2629
// if counters.count > 1 {
2730
// span {}.attributes(.style(["display": "none"]))
@@ -96,13 +99,8 @@ struct TextField {
9699
@Binding<String> var value: String
97100

98101
var content: some View {
99-
// // TODO: make proper two-way binding for DOM elements
100102
input(.type(.text))
101-
.onInput { event in
102-
let text: String = event.targetValue ?? ""
103-
print(event.targetValue ?? "No target value")
104-
_value.wrappedValue = text
105-
}
103+
.bindValue($value)
106104
}
107105
}
108106

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
import ElementaryDOM
2+
import JavaScriptKit
23

34
App().mount(in: .body)

Examples/Basic/watch.sh

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
#!/bin/bash
22
set -e
33

4-
./build-wasi.sh debug
5-
watchexec -w Sources -e .swift -r './build-wasi.sh debug' &
4+
./build-dev.sh debug
5+
watchexec -w Sources -e .swift -r './build-dev.sh debug' &
66
browser-sync start -s -w --ss Public --cwd Public

Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ let package = Package(
3232
.macro(
3333
name: "ElementaryDOMMacros",
3434
dependencies: [
35+
.product(name: "SwiftSyntax", package: "swift-syntax"),
36+
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
3537
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
3638
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
3739
]
@@ -48,6 +50,8 @@ let package = Package(
4850
.macro(
4951
name: "ReactivityMacros",
5052
dependencies: [
53+
.product(name: "SwiftSyntax", package: "swift-syntax"),
54+
.product(name: "SwiftSyntaxBuilder", package: "swift-syntax"),
5155
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
5256
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
5357
]
Lines changed: 247 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,247 @@
1+
protocol DOMElementModifier: AnyObject {
2+
associatedtype Value
3+
4+
static var key: DOMElementModifiers.Key<Self> { get }
5+
6+
init(value: consuming Value, upstream: Self?, _ context: inout _RenderContext)
7+
func updateValue(_ value: consuming Value, _ context: inout _RenderContext)
8+
9+
func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable
10+
}
11+
12+
extension DOMElementModifier {
13+
static var key: DOMElementModifiers.Key<Self> {
14+
DOMElementModifiers.Key(Self.self)
15+
}
16+
}
17+
18+
protocol Unmountable: AnyObject {
19+
func unmount(_ context: inout _CommitContext)
20+
}
21+
22+
struct DOMElementModifiers {
23+
struct Key<Directive: DOMElementModifier> {
24+
let typeID: ObjectIdentifier
25+
26+
init(_: Directive.Type) {
27+
typeID = ObjectIdentifier(Directive.self)
28+
}
29+
}
30+
31+
private var storage: [ObjectIdentifier: any DOMElementModifier] = [:]
32+
33+
subscript<Directive: DOMElementModifier>(_ key: Key<Directive>) -> Directive? {
34+
get {
35+
storage[key.typeID] as? Directive
36+
}
37+
set {
38+
if let newValue = newValue {
39+
storage[key.typeID] = newValue
40+
} else {
41+
storage.removeValue(forKey: key.typeID)
42+
}
43+
}
44+
}
45+
46+
consuming func takeModifiers() -> [any DOMElementModifier] {
47+
let directives = Array(storage.values)
48+
storage.removeAll()
49+
return directives
50+
}
51+
}
52+
53+
struct AnyUnmountable {
54+
private let _unmount: (inout _CommitContext) -> Void
55+
56+
init(_ unmountable: some Unmountable) {
57+
self._unmount = unmountable.unmount(_:)
58+
}
59+
60+
func unmount(_ context: inout _CommitContext) {
61+
_unmount(&context)
62+
}
63+
}
64+
65+
final class BindingModifier<Configuration>: DOMElementModifier, Unmountable where Configuration: BindingConfiguration {
66+
typealias Value = Binding<Configuration.Value>
67+
68+
private var lastValue: Configuration.Value
69+
var binding: Value
70+
71+
var mountedNode: DOM.Node?
72+
var sink: DOM.EventSink?
73+
var accessor: DOM.PropertyAccessor?
74+
var isDirty: Bool = false
75+
76+
init(value: consuming Value, upstream: BindingModifier?, _ context: inout _RenderContext) {
77+
self.lastValue = value.wrappedValue
78+
self.binding = value
79+
}
80+
81+
func updateValue(_ value: consuming Value, _ context: inout _RenderContext) {
82+
self.binding = value
83+
84+
if !Configuration.equals(binding.wrappedValue, lastValue) {
85+
self.lastValue = binding.wrappedValue
86+
markDirty(&context)
87+
}
88+
}
89+
90+
private func markDirty(_ context: inout _RenderContext) {
91+
guard !isDirty else { return }
92+
isDirty = true
93+
94+
context.commitPlan.addNodeAction(
95+
CommitAction(run: updateDOMNode)
96+
)
97+
}
98+
99+
private func updateDOMNode(_ context: inout _CommitContext) {
100+
guard let accessor = self.accessor else { return }
101+
guard let value = Configuration.writeValue(lastValue) else {
102+
logWarning("Cannot set value \(lastValue) to the DOM")
103+
return
104+
}
105+
106+
logTrace("setting value \(value) to accessor")
107+
accessor.set(value)
108+
isDirty = false
109+
}
110+
111+
func mount(_ node: DOM.Node, _ context: inout _CommitContext) -> AnyUnmountable {
112+
if mountedNode != nil {
113+
assertionFailure("Binding effect can only be mounted on a single element")
114+
self.unmount(&context)
115+
}
116+
117+
self.mountedNode = node
118+
self.accessor = context.dom.makePropertyAccessor(node, name: Configuration.propertyName)
119+
120+
let sink = context.dom.makeEventSink { [self] name, event in
121+
guard let value = self.accessor?.get() else {
122+
logWarning("Unexpected property value read from accessor")
123+
return
124+
}
125+
126+
guard let value = Configuration.readValue(value) else {
127+
logWarning("Unexpected property value read from accessor")
128+
return
129+
}
130+
131+
self.lastValue = value
132+
self.binding.wrappedValue = value
133+
}
134+
135+
context.dom.addEventListener(node, event: Configuration.eventName, sink: sink)
136+
return AnyUnmountable(self)
137+
}
138+
139+
func unmount(_ context: inout _CommitContext) {
140+
guard let sink = self.sink, let node = self.mountedNode else {
141+
assertionFailure("Binding effect can only be unmounted on a mounted element")
142+
return
143+
}
144+
145+
context.dom.removeEventListener(node, event: "input", sink: sink)
146+
self.mountedNode = nil
147+
self.sink = nil
148+
}
149+
}
150+
151+
protocol BindingConfiguration {
152+
associatedtype Value
153+
static var propertyName: String { get }
154+
static var eventName: String { get }
155+
static func readValue(_ jsValue: DOM.PropertyValue) -> Value?
156+
static func writeValue(_ value: Value) -> DOM.PropertyValue?
157+
static func equals(_ lhs: Value, _ rhs: Value) -> Bool
158+
}
159+
160+
extension BindingConfiguration where Value == String {
161+
static func equals(_ lhs: Value, _ rhs: Value) -> Bool {
162+
lhs.utf8Equals(rhs)
163+
}
164+
}
165+
166+
extension BindingConfiguration where Value: Equatable {
167+
static func equals(_ lhs: Value, _ rhs: Value) -> Bool {
168+
lhs == rhs
169+
}
170+
}
171+
172+
extension BindingConfiguration where Value == Double {
173+
static func equals(_ lhs: Value, _ rhs: Value) -> Bool {
174+
// a bit hacky, but this is to avoid unnecessary updates when the value is NaN
175+
guard !(lhs.isNaN && rhs.isNaN) else { return true }
176+
return lhs == rhs
177+
}
178+
}
179+
180+
struct TextBindingConfiguration: BindingConfiguration {
181+
typealias Value = String
182+
static var propertyName: String { "value" }
183+
static var eventName: String { "input" }
184+
static func readValue(_ jsValue: DOM.PropertyValue) -> Value? {
185+
switch jsValue {
186+
case let .string(value):
187+
return value
188+
default:
189+
return nil
190+
}
191+
}
192+
static func writeValue(_ value: Value) -> DOM.PropertyValue? {
193+
.string(value)
194+
}
195+
}
196+
197+
struct NumberBindingConfiguration: BindingConfiguration {
198+
typealias Value = Double?
199+
static var propertyName: String { "valueAsNumber" }
200+
static var eventName: String { "input" }
201+
static func readValue(_ jsValue: DOM.PropertyValue) -> Value? {
202+
switch jsValue {
203+
case let .number(value):
204+
value.isNaN ? nil : value
205+
default:
206+
nil
207+
}
208+
}
209+
static func writeValue(_ value: Value) -> DOM.PropertyValue? {
210+
guard let value, !value.isNaN else {
211+
return .undefined
212+
}
213+
214+
return .number(value)
215+
}
216+
}
217+
218+
struct CheckboxBindingConfiguration: BindingConfiguration {
219+
typealias Value = Bool
220+
static var propertyName: String { "checked" }
221+
static var eventName: String { "change" }
222+
static func readValue(_ jsValue: DOM.PropertyValue) -> Value? {
223+
switch jsValue {
224+
case let .boolean(value):
225+
return value
226+
default:
227+
return nil
228+
}
229+
}
230+
static func writeValue(_ value: Value) -> DOM.PropertyValue? {
231+
.boolean(value)
232+
}
233+
}
234+
235+
struct DependencyList: ~Copyable {
236+
private var downstreams: [() -> Void] = []
237+
238+
mutating func add(_ invalidate: @escaping () -> Void) {
239+
downstreams.append(invalidate)
240+
}
241+
242+
func invalidateAll() {
243+
for downstream in downstreams {
244+
downstream()
245+
}
246+
}
247+
}

0 commit comments

Comments
 (0)