@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.
@@ -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
+ }