@octane-xplat/sheet 0.0.1 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +146 -2
- package/dist/native/MaterialBottomSheet.android.tsrx.js +87 -0
- package/dist/native/SwiftUIBottomSheet.ios.tsrx.js +97 -0
- package/dist/native/android/index.js +2 -0
- package/dist/native/ios/index.js +2 -0
- package/dist/native/octane-client-build.json +8 -0
- package/dist/web/BottomSheet.web.tsrx.js +124 -0
- package/dist/web/octane-client-build.json +8 -0
- package/dist/web/sheet.css +58 -0
- package/dist/web/web/index.js +3 -0
- package/package.json +67 -10
- package/platforms/android/include.gradle +15 -0
- package/platforms/android/java/com/octanexplat/sheet/XplatSheetProvider.kt +198 -0
- package/platforms/android/sheet.aar +0 -0
- package/platforms/ios/src/XplatSheetProvider.swift +188 -0
- package/src/AppKitSheet.macos.tsrx +53 -0
- package/src/BottomSheet.web.tsrx +210 -0
- package/src/MaterialBottomSheet.android.tsrx +102 -0
- package/src/SwiftUIBottomSheet.ios.tsrx +119 -0
- package/src/android/index.ts +2 -0
- package/src/android/types.ts +27 -0
- package/src/ios/index.ts +2 -0
- package/src/ios/types.ts +26 -0
- package/src/macos/index.ts +2 -0
- package/src/macos/types.ts +12 -0
- package/src/web/index.ts +4 -0
- package/src/web/styles.css +57 -0
- package/src/web/types.ts +27 -0
- package/types/android/index.d.ts +18 -0
- package/types/ios/index.d.ts +19 -0
- package/types/macos/index.d.ts +10 -0
- package/types/web/index.d.ts +29 -0
- package/index.d.ts +0 -1
- package/index.js +0 -2
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// Adapted from @expo/ui (MIT) — Copyright 2025-present 650 Industries.
|
|
2
|
+
// Upstream: expo/packages/expo-ui/android/.../ui/ModalBottomSheetView.kt
|
|
3
|
+
// (sdk-57). The ExpoModules glue (ComposeProps records, async-function
|
|
4
|
+
// handles, slot views, ModifierRegistry) is replaced by the
|
|
5
|
+
// @nativescript/jetpack-compose provider contract: updateData(Map) + onEvent.
|
|
6
|
+
// The sheet's content is a registered NativeScript view hosted through
|
|
7
|
+
// AndroidView — XplatViewRegistry mirrors the context-menu leaf's registry
|
|
8
|
+
// (same mechanism, kept leaf-local while the shared-bridge question is open).
|
|
9
|
+
|
|
10
|
+
@file:OptIn(ExperimentalMaterial3Api::class)
|
|
11
|
+
|
|
12
|
+
package com.octanexplat.sheet
|
|
13
|
+
|
|
14
|
+
import android.app.Activity
|
|
15
|
+
import android.app.Dialog
|
|
16
|
+
import android.content.Context
|
|
17
|
+
import android.view.KeyEvent
|
|
18
|
+
import android.view.View
|
|
19
|
+
import android.view.ViewGroup
|
|
20
|
+
import android.view.inputmethod.InputMethodManager
|
|
21
|
+
import androidx.compose.foundation.layout.Box
|
|
22
|
+
import androidx.compose.foundation.layout.fillMaxWidth
|
|
23
|
+
import androidx.compose.material3.BottomSheetDefaults
|
|
24
|
+
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
25
|
+
import androidx.compose.material3.ModalBottomSheet
|
|
26
|
+
import androidx.compose.material3.ModalBottomSheetProperties
|
|
27
|
+
import androidx.compose.material3.contentColorFor
|
|
28
|
+
import androidx.compose.material3.rememberModalBottomSheetState
|
|
29
|
+
import androidx.compose.runtime.Composable
|
|
30
|
+
import androidx.compose.runtime.DisposableEffect
|
|
31
|
+
import androidx.compose.runtime.getValue
|
|
32
|
+
import androidx.compose.runtime.mutableStateOf
|
|
33
|
+
import androidx.compose.runtime.setValue
|
|
34
|
+
import androidx.compose.ui.Modifier
|
|
35
|
+
import androidx.compose.ui.platform.ComposeView
|
|
36
|
+
import androidx.compose.ui.platform.LocalView
|
|
37
|
+
import androidx.compose.ui.viewinterop.AndroidView
|
|
38
|
+
import androidx.compose.ui.window.DialogWindowProvider
|
|
39
|
+
import org.json.JSONObject
|
|
40
|
+
import java.lang.ref.WeakReference
|
|
41
|
+
|
|
42
|
+
/** id → NativeScript view registry, callable directly from JS
|
|
43
|
+
* (`com.octanexplat.sheet.XplatViewRegistry.register`). Same mechanism as
|
|
44
|
+
* the context-menu leaf's registry — the sheet content subtree embeds in
|
|
45
|
+
* the modal sheet's dialog window through AndroidView. Weak refs; JS owns
|
|
46
|
+
* the lifecycle and unregisters on unmount. */
|
|
47
|
+
object XplatViewRegistry {
|
|
48
|
+
private val views = HashMap<String, WeakReference<View>>()
|
|
49
|
+
|
|
50
|
+
@JvmStatic
|
|
51
|
+
fun register(id: String, view: View) {
|
|
52
|
+
views[id] = WeakReference(view)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
@JvmStatic
|
|
56
|
+
fun unregister(id: String) {
|
|
57
|
+
views.remove(id)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
@JvmStatic
|
|
61
|
+
fun get(id: String): View? = views[id]?.get()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private data class SheetUiState(
|
|
65
|
+
val open: Boolean = false,
|
|
66
|
+
val contentId: String = "",
|
|
67
|
+
val skipPartiallyExpanded: Boolean = false,
|
|
68
|
+
val showDragHandle: Boolean = true,
|
|
69
|
+
val sheetGesturesEnabled: Boolean = true,
|
|
70
|
+
val shouldDismissOnBackPress: Boolean = true,
|
|
71
|
+
val shouldDismissOnClickOutside: Boolean = true,
|
|
72
|
+
val containerColor: Int? = null,
|
|
73
|
+
val contentColor: Int? = null,
|
|
74
|
+
val scrimColor: Int? = null,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
private fun Any?.asBoolean(): Boolean = when (this) {
|
|
78
|
+
is Boolean -> this
|
|
79
|
+
is Number -> toInt() != 0
|
|
80
|
+
else -> false
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private fun Any?.asColorInt(): Int? = when (this) {
|
|
84
|
+
is Number -> toInt()
|
|
85
|
+
is String -> runCatching { android.graphics.Color.parseColor(this) }.getOrNull()
|
|
86
|
+
else -> null
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** True while the input method has an active connection to a view that
|
|
90
|
+
* accepts text — ported from Expo's ModalBottomSheetView. */
|
|
91
|
+
private fun Activity.isAcceptingText(): Boolean {
|
|
92
|
+
val inputMethodManager = getSystemService(Context.INPUT_METHOD_SERVICE) as? InputMethodManager
|
|
93
|
+
return inputMethodManager?.isAcceptingText == true
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/** Ported: the sheet lives in its own dialog window, so key events that the
|
|
97
|
+
* activity owns (e.g. dev-menu triggers) stop reaching it while the sheet
|
|
98
|
+
* is open — forward non-dismiss keys back, skipping while a field accepts
|
|
99
|
+
* text so typing can't trigger activity shortcuts. */
|
|
100
|
+
@Composable
|
|
101
|
+
private fun ForwardKeyEventsToActivity(activity: Activity?) {
|
|
102
|
+
val dialog = (LocalView.current.parent as? DialogWindowProvider)?.window?.callback as? Dialog
|
|
103
|
+
if (dialog == null || activity == null) {
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
DisposableEffect(dialog, activity) {
|
|
107
|
+
dialog.setOnKeyListener { _, keyCode, event ->
|
|
108
|
+
val isForwardable = event.action == KeyEvent.ACTION_UP &&
|
|
109
|
+
keyCode != KeyEvent.KEYCODE_BACK &&
|
|
110
|
+
keyCode != KeyEvent.KEYCODE_ESCAPE &&
|
|
111
|
+
!activity.isAcceptingText()
|
|
112
|
+
isForwardable && activity.onKeyUp(keyCode, event)
|
|
113
|
+
}
|
|
114
|
+
onDispose {
|
|
115
|
+
dialog.setOnKeyListener(null)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
@Composable
|
|
121
|
+
private fun SheetContent(state: SheetUiState, activity: Activity?, onDismiss: () -> Unit) {
|
|
122
|
+
if (!state.open) {
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
val sheetState = rememberModalBottomSheetState(state.skipPartiallyExpanded)
|
|
126
|
+
val resolvedContainer = state.containerColor?.let { androidx.compose.ui.graphics.Color(it) }
|
|
127
|
+
?: BottomSheetDefaults.ContainerColor
|
|
128
|
+
val resolvedContent = state.contentColor?.let { androidx.compose.ui.graphics.Color(it) }
|
|
129
|
+
?: contentColorFor(resolvedContainer)
|
|
130
|
+
val resolvedScrim = state.scrimColor?.let { androidx.compose.ui.graphics.Color(it) }
|
|
131
|
+
?: BottomSheetDefaults.ScrimColor
|
|
132
|
+
|
|
133
|
+
// This material3 predates sheetGesturesEnabled and
|
|
134
|
+
// shouldDismissOnClickOutside — only shouldDismissOnBackPress is
|
|
135
|
+
// carried through properties.
|
|
136
|
+
ModalBottomSheet(
|
|
137
|
+
sheetState = sheetState,
|
|
138
|
+
onDismissRequest = onDismiss,
|
|
139
|
+
containerColor = resolvedContainer,
|
|
140
|
+
contentColor = resolvedContent,
|
|
141
|
+
scrimColor = resolvedScrim,
|
|
142
|
+
dragHandle = if (state.showDragHandle) {
|
|
143
|
+
{ BottomSheetDefaults.DragHandle() }
|
|
144
|
+
} else {
|
|
145
|
+
null
|
|
146
|
+
},
|
|
147
|
+
properties = ModalBottomSheetProperties(
|
|
148
|
+
shouldDismissOnBackPress = state.shouldDismissOnBackPress,
|
|
149
|
+
),
|
|
150
|
+
) {
|
|
151
|
+
ForwardKeyEventsToActivity(activity)
|
|
152
|
+
Box(modifier = Modifier.fillMaxWidth()) {
|
|
153
|
+
val contentId = state.contentId
|
|
154
|
+
if (contentId.isNotEmpty()) {
|
|
155
|
+
AndroidView(
|
|
156
|
+
factory = { ctx ->
|
|
157
|
+
XplatViewRegistry.get(contentId) ?: View(ctx).apply {
|
|
158
|
+
layoutParams = ViewGroup.LayoutParams(0, 0)
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
class XplatSheetProvider {
|
|
168
|
+
private var state by mutableStateOf(SheetUiState())
|
|
169
|
+
var onEvent: ((Any) -> Unit)? = null
|
|
170
|
+
|
|
171
|
+
fun generateComposeView(view: ComposeView) {
|
|
172
|
+
view.setContent {
|
|
173
|
+
androidx.compose.material3.MaterialTheme {
|
|
174
|
+
SheetContent(
|
|
175
|
+
state = state,
|
|
176
|
+
activity = (view.context as? Activity),
|
|
177
|
+
onDismiss = { onEvent?.invoke(JSONObject().put("dismissed", true)) },
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
fun updateData(input: Map<Any, Any>) {
|
|
184
|
+
val data = input["data"] as? Map<*, *> ?: input
|
|
185
|
+
state = SheetUiState(
|
|
186
|
+
open = data["open"].asBoolean(),
|
|
187
|
+
contentId = data["contentId"] as? String ?: "",
|
|
188
|
+
skipPartiallyExpanded = data["skipPartiallyExpanded"].asBoolean(),
|
|
189
|
+
showDragHandle = if (data.containsKey("showDragHandle")) data["showDragHandle"].asBoolean() else true,
|
|
190
|
+
sheetGesturesEnabled = if (data.containsKey("sheetGesturesEnabled")) data["sheetGesturesEnabled"].asBoolean() else true,
|
|
191
|
+
shouldDismissOnBackPress = if (data.containsKey("shouldDismissOnBackPress")) data["shouldDismissOnBackPress"].asBoolean() else true,
|
|
192
|
+
shouldDismissOnClickOutside = if (data.containsKey("shouldDismissOnClickOutside")) data["shouldDismissOnClickOutside"].asBoolean() else true,
|
|
193
|
+
containerColor = data["containerColor"].asColorInt(),
|
|
194
|
+
contentColor = data["contentColor"].asColorInt(),
|
|
195
|
+
scrimColor = data["scrimColor"].asColorInt(),
|
|
196
|
+
)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
Binary file
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Adapted from @expo/ui (MIT) — Copyright 2025-present 650 Industries.
|
|
2
|
+
// Upstream: expo/packages/expo-ui/ios/BottomSheetView.swift (sdk-57).
|
|
3
|
+
// The ExpoModules glue (@Field props record, EventDispatcher, children slot
|
|
4
|
+
// views) is replaced by the @nativescript/swift-ui provider contract:
|
|
5
|
+
// updateData(NSDictionary) + onEvent. The sheet's content is a registered
|
|
6
|
+
// NativeScript view resolved through NativeScriptViewFactory — the same
|
|
7
|
+
// HostedNativeScriptView the context-menu leaf re-declares (the plugin's
|
|
8
|
+
// NativeScriptView struct is module-internal).
|
|
9
|
+
|
|
10
|
+
import SwiftUI
|
|
11
|
+
|
|
12
|
+
private final class SheetModel: ObservableObject {
|
|
13
|
+
@Published var isPresented: Bool = false
|
|
14
|
+
@Published var contentId: String = ""
|
|
15
|
+
@Published var fitToContents: Bool = false
|
|
16
|
+
@Published var detents: [String] = ["medium"]
|
|
17
|
+
@Published var customHeight: Double? = nil
|
|
18
|
+
@Published var customFraction: Double? = nil
|
|
19
|
+
@Published var showDragIndicator: Bool = false
|
|
20
|
+
@Published var interactiveDismissDisabled: Bool = false
|
|
21
|
+
var onPresentedChange: ((Bool) -> Void)?
|
|
22
|
+
var onDismiss: (() -> Void)?
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Same shape as the plugin's NativeScriptViewRepresentable — unique instance
|
|
26
|
+
// id resolved through the core-owned NativeScriptViewFactory.
|
|
27
|
+
private struct HostedNativeScriptView: UIViewRepresentable {
|
|
28
|
+
let id: String
|
|
29
|
+
|
|
30
|
+
init(id: String) {
|
|
31
|
+
self.id = id + "-" + UUID().uuidString
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func makeUIView(context: Context) -> UIView {
|
|
35
|
+
return NativeScriptViewFactory.shared?.getViewById(id) ?? UIView()
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
func updateUIView(_ uiView: UIView, context: Context) {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
private struct SizePreferenceKey: PreferenceKey {
|
|
42
|
+
static var defaultValue: CGSize?
|
|
43
|
+
|
|
44
|
+
static func reduce(value: inout CGSize?, nextValue: () -> CGSize?) {
|
|
45
|
+
guard let nextValue = nextValue() else {
|
|
46
|
+
return
|
|
47
|
+
}
|
|
48
|
+
value = nextValue
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
private struct ReadSizeModifier: ViewModifier {
|
|
53
|
+
private var sizeView: some View {
|
|
54
|
+
GeometryReader { geometry in
|
|
55
|
+
Color.clear
|
|
56
|
+
.preference(key: SizePreferenceKey.self, value: geometry.size)
|
|
57
|
+
.allowsHitTesting(false)
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func body(content: Content) -> some View {
|
|
62
|
+
content.background(sizeView)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@available(iOS 14.0, *)
|
|
67
|
+
private struct SheetContent: View {
|
|
68
|
+
@ObservedObject var model: SheetModel
|
|
69
|
+
@State private var isPresented: Bool = false
|
|
70
|
+
@State private var childrenSize: CGSize = .zero
|
|
71
|
+
|
|
72
|
+
private func handleSizeChange(_ size: CGSize) {
|
|
73
|
+
guard childrenSize != size else { return }
|
|
74
|
+
childrenSize = size
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
@available(iOS 16.0, tvOS 16.0, *)
|
|
78
|
+
private var presentationDetents: Set<PresentationDetent> {
|
|
79
|
+
if model.fitToContents, childrenSize.height > 0 {
|
|
80
|
+
return [.height(childrenSize.height)]
|
|
81
|
+
}
|
|
82
|
+
var result: Set<PresentationDetent> = []
|
|
83
|
+
for detent in model.detents {
|
|
84
|
+
switch detent {
|
|
85
|
+
case "large": result.insert(.large)
|
|
86
|
+
case "fraction": result.insert(.fraction(model.customFraction ?? 0.5))
|
|
87
|
+
case "height": result.insert(.height(model.customHeight ?? 300))
|
|
88
|
+
default: result.insert(.medium)
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result.isEmpty ? [.medium] : result
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
var body: some View {
|
|
95
|
+
Color.clear
|
|
96
|
+
.frame(width: 0, height: 0)
|
|
97
|
+
.sheet(isPresented: $isPresented, onDismiss: {
|
|
98
|
+
model.onDismiss?()
|
|
99
|
+
}) {
|
|
100
|
+
let content = HostedNativeScriptView(id: model.contentId)
|
|
101
|
+
.modifier(ReadSizeModifier())
|
|
102
|
+
.onPreferenceChange(SizePreferenceKey.self) { size in
|
|
103
|
+
if let size { handleSizeChange(size) }
|
|
104
|
+
}
|
|
105
|
+
if #available(iOS 16.0, tvOS 16.0, *) {
|
|
106
|
+
content
|
|
107
|
+
.presentationDetents(presentationDetents)
|
|
108
|
+
.presentationDragIndicator(model.showDragIndicator ? .visible : .hidden)
|
|
109
|
+
.interactiveDismissDisabled(model.interactiveDismissDisabled)
|
|
110
|
+
} else {
|
|
111
|
+
content
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
.onChange(of: isPresented) { newIsPresented in
|
|
115
|
+
if model.isPresented == newIsPresented {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
model.onPresentedChange?(newIsPresented)
|
|
119
|
+
}
|
|
120
|
+
.onChange(of: model.isPresented) { newValue in
|
|
121
|
+
isPresented = newValue
|
|
122
|
+
}
|
|
123
|
+
.onAppear {
|
|
124
|
+
isPresented = model.isPresented
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
@objc(XplatSheetProvider)
|
|
130
|
+
public final class XplatSheetProvider: UIViewController, SwiftUIProvider {
|
|
131
|
+
private let model = SheetModel()
|
|
132
|
+
|
|
133
|
+
public required init() {
|
|
134
|
+
super.init(nibName: nil, bundle: nil)
|
|
135
|
+
model.onPresentedChange = { [weak self] presented in
|
|
136
|
+
self?.onEvent?(["isPresented": presented])
|
|
137
|
+
}
|
|
138
|
+
model.onDismiss = { [weak self] in
|
|
139
|
+
self?.onEvent?(["dismissed": true])
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
public required init?(coder: NSCoder) {
|
|
144
|
+
super.init(coder: coder)
|
|
145
|
+
model.onPresentedChange = { [weak self] presented in
|
|
146
|
+
self?.onEvent?(["isPresented": presented])
|
|
147
|
+
}
|
|
148
|
+
model.onDismiss = { [weak self] in
|
|
149
|
+
self?.onEvent?(["dismissed": true])
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
public override func viewDidLoad() {
|
|
154
|
+
super.viewDidLoad()
|
|
155
|
+
if #available(iOS 14.0, *) {
|
|
156
|
+
setupSwiftUIView(content: SheetContent(model: model))
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
public func updateData(data: NSDictionary) {
|
|
161
|
+
if let presented = data["isPresented"] as? NSNumber {
|
|
162
|
+
model.isPresented = presented.boolValue
|
|
163
|
+
}
|
|
164
|
+
if let contentId = data["contentId"] as? String {
|
|
165
|
+
model.contentId = contentId
|
|
166
|
+
}
|
|
167
|
+
if let fit = data["fitToContents"] as? NSNumber {
|
|
168
|
+
model.fitToContents = fit.boolValue
|
|
169
|
+
}
|
|
170
|
+
if let detents = data["detents"] as? [String] {
|
|
171
|
+
model.detents = detents
|
|
172
|
+
}
|
|
173
|
+
if let height = data["customHeight"] as? NSNumber {
|
|
174
|
+
model.customHeight = height.doubleValue
|
|
175
|
+
}
|
|
176
|
+
if let fraction = data["customFraction"] as? NSNumber {
|
|
177
|
+
model.customFraction = fraction.doubleValue
|
|
178
|
+
}
|
|
179
|
+
if let indicator = data["showDragIndicator"] as? NSNumber {
|
|
180
|
+
model.showDragIndicator = indicator.boolValue
|
|
181
|
+
}
|
|
182
|
+
if let disable = data["interactiveDismissDisabled"] as? NSNumber {
|
|
183
|
+
model.interactiveDismissDisabled = disable.boolValue
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
public var onEvent: ((NSDictionary) -> Void)?
|
|
188
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/** @jsxImportSource @octane-xplat/macos-renderer */
|
|
2
|
+
import { useEffect, useRef } from 'octane'
|
|
3
|
+
import type { AppKitSheetProps } from './macos/types'
|
|
4
|
+
|
|
5
|
+
const host = (): any => (globalThis as any).__xplatAppKit ?? {}
|
|
6
|
+
|
|
7
|
+
function ContentRoot(props: { render?: () => any }) @{
|
|
8
|
+
<>{props.render?.()}</>;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** macOS sheet — presents the `content` subtree in a real AppKit window
|
|
12
|
+
* sheet (`beginSheet` on the leaf's parent window), falling back to a
|
|
13
|
+
* floating window when no parent exists. `open` controls presentation;
|
|
14
|
+
* closing the sheet window (Esc/close button) or `open`→false fires
|
|
15
|
+
* `onDismissed`. Detents don't exist on macOS — the window sizes to the
|
|
16
|
+
* content's fitting size. */
|
|
17
|
+
export function AppKitSheet(props: AppKitSheetProps) @{
|
|
18
|
+
const anchor = useRef<any>(null)
|
|
19
|
+
const presented = useRef<any>(null)
|
|
20
|
+
|
|
21
|
+
useEffect(() => {
|
|
22
|
+
if (!props.open) return
|
|
23
|
+
if (presented.current) {
|
|
24
|
+
presented.current.update({ render: props.content })
|
|
25
|
+
return
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const view = anchor.current
|
|
29
|
+
const present = host().presentSheet
|
|
30
|
+
if (!view || typeof present !== 'function') return
|
|
31
|
+
presented.current = present(view, {
|
|
32
|
+
component: ContentRoot,
|
|
33
|
+
props: { render: props.content },
|
|
34
|
+
onDismissed: () => {
|
|
35
|
+
presented.current = null
|
|
36
|
+
props.onDismissed?.()
|
|
37
|
+
},
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return () => {
|
|
41
|
+
const entry = presented.current
|
|
42
|
+
presented.current = null
|
|
43
|
+
entry?.close()
|
|
44
|
+
}
|
|
45
|
+
}, [props.open])
|
|
46
|
+
|
|
47
|
+
// Push content render-fn changes into the presented sheet while open.
|
|
48
|
+
useEffect(() => {
|
|
49
|
+
presented.current?.update?.({ render: props.content })
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
<flexboxlayout ref={(v: any) => { anchor.current = v }} id={props.id} />;
|
|
53
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/** @jsxImportSource octane */
|
|
2
|
+
import { useEffect, useRef } from 'octane'
|
|
3
|
+
import type { BottomSheetProps, WebFocusTarget } from './web/types'
|
|
4
|
+
|
|
5
|
+
function tabStops(panel: HTMLElement): HTMLElement[] {
|
|
6
|
+
return Array.from(
|
|
7
|
+
panel.querySelectorAll<HTMLElement>(
|
|
8
|
+
'a[href],button,input:not([type="hidden"]),textarea,select,[tabindex],[contenteditable="true"]',
|
|
9
|
+
),
|
|
10
|
+
).filter((element) => {
|
|
11
|
+
if (
|
|
12
|
+
element.tabIndex < 0 ||
|
|
13
|
+
element.matches(':disabled') ||
|
|
14
|
+
element.closest('[inert],[hidden],[aria-hidden="true"]')
|
|
15
|
+
) {
|
|
16
|
+
return false
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
for (let parent: HTMLElement | null = element; parent; parent = parent.parentElement) {
|
|
20
|
+
const style = getComputedStyle(parent)
|
|
21
|
+
if (style.display === 'none' || style.visibility === 'hidden') {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return true
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Web bottom sheet: a modal browser dialog. `open` controls visibility;
|
|
31
|
+
* `onDismissed` fires for enabled Escape, scrim, and drag dismissals. */
|
|
32
|
+
export function BottomSheet(props: BottomSheetProps) @{
|
|
33
|
+
const dialog = useRef<HTMLDialogElement | null>(null)
|
|
34
|
+
const panel = useRef<HTMLDivElement | null>(null)
|
|
35
|
+
const drag = useRef<{ pointerId: number; startY: number; offsetY: number } | null>(null)
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const current = dialog.current
|
|
39
|
+
if (!props.open || !current || current.open) {
|
|
40
|
+
return
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const trigger =
|
|
44
|
+
props.finalFocusRef?.current ?? (current.ownerDocument.activeElement as WebFocusTarget | null)
|
|
45
|
+
|
|
46
|
+
const previousTabIndex = current.getAttribute('tabindex')
|
|
47
|
+
current.showModal()
|
|
48
|
+
const stops = panel.current ? tabStops(panel.current) : []
|
|
49
|
+
const autofocus = current.querySelector<HTMLElement>('[autofocus]')
|
|
50
|
+
const initialFocus =
|
|
51
|
+
(autofocus && !autofocus.matches(':disabled,[hidden],[inert]') ? autofocus : null) ??
|
|
52
|
+
stops[0] ??
|
|
53
|
+
current
|
|
54
|
+
|
|
55
|
+
if (initialFocus === current && previousTabIndex === null) {
|
|
56
|
+
current.tabIndex = -1
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
initialFocus.focus({ preventScroll: true })
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
if (current.open) {
|
|
63
|
+
current.close()
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (previousTabIndex === null && initialFocus === current) {
|
|
67
|
+
current.removeAttribute('tabindex')
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
requestAnimationFrame(() => {
|
|
71
|
+
if (trigger?.isConnected && !trigger.closest('[inert]')) {
|
|
72
|
+
trigger.focus({ preventScroll: true })
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
}
|
|
76
|
+
}, [props.open])
|
|
77
|
+
|
|
78
|
+
if (!props.open || typeof document === 'undefined') {
|
|
79
|
+
return <></>
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const resetDrag = () => {
|
|
83
|
+
drag.current = null
|
|
84
|
+
panel.current?.style.removeProperty('--xplat-sheet-drag-offset')
|
|
85
|
+
panel.current?.style.removeProperty('transition')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const onPointerDown = (event: PointerEvent) => {
|
|
89
|
+
if (props.sheetGesturesEnabled === false || !event.isPrimary || event.button !== 0) {
|
|
90
|
+
return
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
drag.current = { pointerId: event.pointerId, startY: event.clientY, offsetY: 0 }
|
|
94
|
+
panel.current?.style.setProperty('transition', 'none')
|
|
95
|
+
|
|
96
|
+
;(event.currentTarget as HTMLElement).setPointerCapture(event.pointerId)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const onPointerMove = (event: PointerEvent) => {
|
|
100
|
+
const active = drag.current
|
|
101
|
+
if (!active || active.pointerId !== event.pointerId) {
|
|
102
|
+
return
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
active.offsetY = Math.max(0, event.clientY - active.startY)
|
|
106
|
+
panel.current?.style.setProperty('--xplat-sheet-drag-offset', `${active.offsetY}px`)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const onPointerUp = (event: PointerEvent) => {
|
|
110
|
+
const active = drag.current
|
|
111
|
+
if (!active || active.pointerId !== event.pointerId) {
|
|
112
|
+
return
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const shouldDismiss = active.offsetY >= Math.min(120, window.innerHeight * 0.18)
|
|
116
|
+
resetDrag()
|
|
117
|
+
|
|
118
|
+
if (shouldDismiss && props.sheetGesturesEnabled !== false) {
|
|
119
|
+
props.onDismissed?.()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const onCancel = (event: Event) => {
|
|
124
|
+
event.preventDefault()
|
|
125
|
+
if (props.shouldDismissOnBackPress ?? true) {
|
|
126
|
+
props.onDismissed?.()
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const onKeyDown = (event: KeyboardEvent) => {
|
|
131
|
+
if (event.key !== 'Tab') {
|
|
132
|
+
return
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const current = dialog.current
|
|
136
|
+
const active = current?.ownerDocument.activeElement
|
|
137
|
+
if (!current?.open || (active !== current && !panel.current?.contains(active))) {
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const stops = panel.current ? tabStops(panel.current) : []
|
|
142
|
+
const index = stops.indexOf(active as HTMLElement)
|
|
143
|
+
event.preventDefault()
|
|
144
|
+
if (!stops.length) {
|
|
145
|
+
dialog.current?.focus({ preventScroll: true })
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const nextIndex = event.shiftKey
|
|
150
|
+
? index <= 0 ? stops.length - 1 : index - 1
|
|
151
|
+
: index < 0 || index === stops.length - 1 ? 0 : index + 1
|
|
152
|
+
|
|
153
|
+
stops[nextIndex].focus({ preventScroll: true })
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
useEffect(() => {
|
|
157
|
+
const current = dialog.current
|
|
158
|
+
if (!props.open || !current) {
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
current.ownerDocument.addEventListener('keydown', onKeyDown, true)
|
|
163
|
+
return () => current.ownerDocument.removeEventListener('keydown', onKeyDown, true)
|
|
164
|
+
}, [props.open])
|
|
165
|
+
|
|
166
|
+
<>
|
|
167
|
+
<dialog
|
|
168
|
+
ref={dialog}
|
|
169
|
+
className="xplat-web-sheet"
|
|
170
|
+
aria-label={props.label ?? 'Bottom sheet'}
|
|
171
|
+
aria-modal="true"
|
|
172
|
+
style={{
|
|
173
|
+
'--xplat-sheet-scrim': props.scrimColor ?? 'rgb(0 0 0 / 0.4)',
|
|
174
|
+
'--xplat-sheet-container': props.containerColor ?? 'var(--sheet-bg, Canvas)',
|
|
175
|
+
'--xplat-sheet-content': props.contentColor ?? 'var(--sheet-fg, CanvasText)',
|
|
176
|
+
'--xplat-sheet-max-height': props.skipPartiallyExpanded ? '90vh' : '60vh',
|
|
177
|
+
}}
|
|
178
|
+
onCancel={onCancel}
|
|
179
|
+
onClick={(event: MouseEvent) => {
|
|
180
|
+
if (
|
|
181
|
+
event.target === event.currentTarget &&
|
|
182
|
+
(props.shouldDismissOnClickOutside ?? true)
|
|
183
|
+
) {
|
|
184
|
+
props.onDismissed?.()
|
|
185
|
+
}
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<div
|
|
189
|
+
ref={panel}
|
|
190
|
+
id={props.id}
|
|
191
|
+
className={'xplat-web-sheet-panel ' + (props.className ?? '')}
|
|
192
|
+
style={props.style}
|
|
193
|
+
>
|
|
194
|
+
{(props.showDragHandle ?? true) && (
|
|
195
|
+
<div
|
|
196
|
+
className="xplat-web-sheet-handle"
|
|
197
|
+
aria-hidden="true"
|
|
198
|
+
onPointerDown={onPointerDown}
|
|
199
|
+
onPointerMove={onPointerMove}
|
|
200
|
+
onPointerUp={onPointerUp}
|
|
201
|
+
onPointerCancel={resetDrag}
|
|
202
|
+
>
|
|
203
|
+
<div className="xplat-web-sheet-handle-mark" />
|
|
204
|
+
</div>
|
|
205
|
+
)}
|
|
206
|
+
{props.content?.()}
|
|
207
|
+
</div>
|
|
208
|
+
</dialog>
|
|
209
|
+
</>
|
|
210
|
+
}
|