Skip to content

Commit 158b5dc

Browse files
authored
Add data-confirm attribute support for all events (#1500)
* Add `data-confirm` attribute support for all events * Update for Swift concurrency
1 parent a2eff26 commit 158b5dc

File tree

6 files changed

+110
-39
lines changed

6 files changed

+110
-39
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1313
- `NavigationLink` supports the `data-phx-link` attribute to switch between `redirect` (default) and `patch` navigation
1414
- `NavigationLink` can perform a replace navigation by setting the `data-phx-link-state` attribute to `replace`
1515
- `NavigationLink` takes a `destination` template to customize the connecting phase View for its navigation event.
16+
- The `data-confirm` attribute can be added to elements to show a confirmation dialog before sending an event
1617

1718
### Changed
1819
- Swift 6 is now required to build LiveView Native applications

Sources/LiveViewNative/Coordinators/LiveSessionConfiguration.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,8 @@ public struct LiveSessionConfiguration {
3333

3434
public var reconnectBehavior: ReconnectBehavior = .exponential
3535

36+
public var eventConfirmation: ((String, ElementNode) async -> Bool)?
37+
3638
/// Constructs a default, empty configuration.
3739
public init() {
3840
}
@@ -42,13 +44,15 @@ public struct LiveSessionConfiguration {
4244
headers: [String: String]? = nil,
4345
urlSessionConfiguration: URLSessionConfiguration = .default,
4446
transition: AnyTransition? = nil,
45-
reconnectBehavior: ReconnectBehavior = .exponential
47+
reconnectBehavior: ReconnectBehavior = .exponential,
48+
eventConfirmation: ((String, ElementNode) async -> Bool)? = nil
4649
) {
4750
self.headers = headers
4851
self.connectParams = connectParams
4952
self.urlSessionConfiguration = urlSessionConfiguration
5053
self.transition = transition
5154
self.reconnectBehavior = reconnectBehavior
55+
self.eventConfirmation = eventConfirmation
5256
}
5357

5458
public struct ReconnectBehavior: Sendable {

Sources/LiveViewNative/Live/LiveView.swift

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,13 @@ public struct LiveView<
150150
let reconnectingView: (_ConnectedContent<R>, Bool) -> ReconnectingView
151151
let errorView: (Error) -> ErrorView
152152

153+
@State private var showEventConfirmation: Bool = false
154+
@State private var eventConfirmationTransaction: EventConfirmationTransaction?
155+
struct EventConfirmationTransaction: Sendable {
156+
let message: String
157+
let callback: @Sendable (sending Bool) -> ()
158+
}
159+
153160
@MainActor
154161
@ViewBuilder
155162
func buildPhaseView(_ phase: LiveViewPhase<R>) -> some View {
@@ -235,6 +242,26 @@ public struct LiveView<
235242
await session.connect()
236243
}
237244
}
245+
// data-confirm
246+
.environment(\.eventConfirmation, session.configuration.eventConfirmation ?? { message, _ in
247+
return await withCheckedContinuation { continuation in
248+
eventConfirmationTransaction = EventConfirmationTransaction(message: message, callback: continuation.resume(returning:))
249+
showEventConfirmation = true
250+
}
251+
})
252+
.confirmationDialog(
253+
eventConfirmationTransaction?.message ?? "",
254+
isPresented: $showEventConfirmation,
255+
titleVisibility: .visible,
256+
presenting: eventConfirmationTransaction
257+
) { transaction in
258+
SwiftUI.Button("OK") {
259+
transaction.callback(true)
260+
}
261+
SwiftUI.Button("Cancel", role: .cancel) {
262+
transaction.callback(false)
263+
}
264+
}
238265
}
239266
}
240267

Sources/LiveViewNative/Property Wrappers/Event.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ import AsyncAlgorithms
7070
public struct Event: @preconcurrency DynamicProperty, @preconcurrency Decodable {
7171
@ObservedElement private var element: ElementNode
7272
@Environment(\.coordinatorEnvironment) private var coordinatorEnvironment
73+
@Environment(\.eventConfirmation) private var eventConfirmation
7374
@StateObject var handler = Handler()
7475
/// The name of the event to send.
7576
@_documentation(visibility: public)
@@ -336,6 +337,11 @@ public struct Event: @preconcurrency DynamicProperty, @preconcurrency Decodable
336337
guard let event else {
337338
return
338339
}
340+
if let confirm = try owner.element.attributeValue(for: "data-confirm"),
341+
let eventConfirmation = owner.eventConfirmation
342+
{
343+
guard await eventConfirmation(confirm, owner.element) else { return }
344+
}
339345
await owner.handler.channel.send(.init(
340346
type: owner.type,
341347
event: event,
@@ -348,6 +354,11 @@ public struct Event: @preconcurrency DynamicProperty, @preconcurrency Decodable
348354
guard let event else {
349355
return
350356
}
357+
if let confirm = try owner.element.attributeValue(for: "data-confirm"),
358+
let eventConfirmation = owner.eventConfirmation
359+
{
360+
guard await eventConfirmation(confirm, owner.element) else { return }
361+
}
351362
let handler = Handler()
352363
handler.update(
353364
coordinator: CoordinatorEnvironment(context.coordinator, document: context.coordinator.document!),
@@ -363,3 +374,14 @@ public struct Event: @preconcurrency DynamicProperty, @preconcurrency Decodable
363374
}
364375
}
365376
}
377+
378+
extension EnvironmentValues {
379+
private enum EventConfirmationKey: EnvironmentKey {
380+
static var defaultValue: ((String, ElementNode) async -> Bool)? { nil }
381+
}
382+
383+
var eventConfirmation: ((String, ElementNode) async -> Bool)? {
384+
get { self[EventConfirmationKey.self] }
385+
set { self[EventConfirmationKey.self] = newValue }
386+
}
387+
}

Sources/LiveViewNative/Utils/DOM.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import LiveViewNativeCore
2323
/// - ``depthFirstChildren()``
2424
/// - ``elementChildren()``
2525
/// - ``innerText()``
26-
public struct ElementNode: Identifiable {
26+
public struct ElementNode: Identifiable, @unchecked Sendable {
2727
public let node: Node
2828
public let data: ElementData
2929

lib/live_view_native/swiftui/component.ex

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -80,46 +80,63 @@ defmodule LiveViewNative.SwiftUI.Component do
8080

8181
# ### Overriding the default confirm behaviour
8282

83-
# `phoenix_html.js` does trigger a custom event `phoenix.link.click` on the clicked DOM element
84-
# when a click happened. This allows you to intercept the event on its way bubbling up
85-
# to `window` and do your own custom logic to enhance or replace how the `data-confirm`
86-
# attribute is handled. You could for example replace the browsers `confirm()` behavior with
87-
# a custom javascript implementation:
88-
89-
# ```javascript
90-
# // listen on document.body, so it's executed before the default of
91-
# // phoenix_html, which is listening on the window object
92-
# document.body.addEventListener('phoenix.link.click', function (e) {
93-
# // Prevent default implementation
94-
# e.stopPropagation();
95-
# // Introduce alternative implementation
96-
# var message = e.target.getAttribute("data-confirm");
97-
# if(!message){ return true; }
98-
# vex.dialog.confirm({
99-
# message: message,
100-
# callback: function (value) {
101-
# if (value == false) { e.preventDefault(); }
102-
# }
103-
# })
104-
# }, false);
105-
# ```
106-
107-
# Or you could attach your own custom behavior.
108-
109-
# ```javascript
110-
# window.addEventListener('phoenix.link.click', function (e) {
111-
# // Introduce custom behaviour
112-
# var message = e.target.getAttribute("data-prompt");
113-
# var answer = e.target.getAttribute("data-prompt-answer");
114-
# if(message && answer && (answer != window.prompt(message))) {
115-
# e.preventDefault();
83+
# You can customize the confirm dialog in your app's client code.
84+
# Any event on an element with a `data-confirm` attribute will first call the provided
85+
# `eventConfirmation` function. Provide a custom function with a `(String, ElementNode) async -> Bool`
86+
# signature to show a custom dialog.
87+
88+
# ```swift
89+
# struct ContentView: View {
90+
# @State private var showEventConfirmation: Bool = false
91+
# @State private var eventConfirmationTransaction: EventConfirmationTransaction?
92+
# struct EventConfirmationTransaction: Sendable, Identifiable {
93+
# let id = UUID()
94+
# let message: String
95+
# let role: ButtonRole?
96+
# let callback: @Sendable (sending Bool) -> ()
11697
# }
117-
# }, false);
98+
#
99+
# var body: some View {
100+
# #LiveView(
101+
# .localhost,
102+
# configuration: LiveSessionConfiguration(eventConfirmation: { message, element in
103+
# return await withCheckedContinuation { @MainActor continuation in
104+
# showEventConfirmation = true
105+
# eventConfirmationTransaction = EventConfirmationTransaction(
106+
# message: message,
107+
# role: try? element.attributeValue(ButtonRole.self, for: "data-confirm-role"), // access a custom attribute
108+
# callback: continuation.resume(returning:)
109+
# )
110+
# }
111+
# }),
112+
# addons: [.liveForm]
113+
# ) {
114+
# ConnectingView()
115+
# } disconnected: {
116+
# DisconnectedView()
117+
# } reconnecting: { content, isReconnecting in
118+
# ReconnectingView(isReconnecting: isReconnecting) {
119+
# content
120+
# }
121+
# } error: { error in
122+
# ErrorView(error: error)
123+
# }
124+
# .alert(
125+
# eventConfirmationTransaction?.message ?? "",
126+
# isPresented: $showEventConfirmation,
127+
# presenting: eventConfirmationTransaction
128+
# ) { transaction in
129+
# Button("Confirm", role: transaction.role) {
130+
# transaction.callback(true)
131+
# }
132+
# Button("Cancel", role: .cancel) {
133+
# transaction.callback(false)
134+
# }
135+
# }
136+
# }
137+
# }
118138
# ```
119139

120-
# The latter could also be bound to any `click` event, but this way you can be sure your custom
121-
# code is only executed when the code of `phoenix_html.js` is run.
122-
123140
# ## CSRF Protection
124141

125142
# By default, CSRF tokens are generated through `Plug.CSRFProtection`.

0 commit comments

Comments
 (0)