diff --git a/nativephp.json b/nativephp.json index 24f88c4..921fd0c 100644 --- a/nativephp.json +++ b/nativephp.json @@ -468,6 +468,14 @@ "android_renderer": "com.nativephp.plugins.native_ui.ui.EmptyRenderer", "ios_renderer": "NativeUIEmptyRenderer", "self_closing": false + }, + { + "type": "sheet_pane", + "element": "Native\\Mobile\\UI\\Elements\\SheetPane", + "blade": "Native\\Mobile\\UI\\Components\\SheetPane", + "ios_renderer": "NativeUISheetPaneRenderer", + "self_closing": false, + "android_renderer": "com.nativephp.plugins.native_ui.ui.SheetPaneRenderer" } ], "bridge_functions": [ diff --git a/resources/android/BottomSheetRenderer.kt b/resources/android/BottomSheetRenderer.kt index c4230b3..18b733b 100644 --- a/resources/android/BottomSheetRenderer.kt +++ b/resources/android/BottomSheetRenderer.kt @@ -6,6 +6,8 @@ import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.material3.BottomSheetDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.ModalBottomSheetProperties +import androidx.compose.material3.SheetValue import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier @@ -39,24 +41,35 @@ object BottomSheetRenderer { val onDismissCb = p.getCallbackId("on_dismiss") val detentsStr = p.getString("detents", "medium,large") val a11yLabel = p.getString("a11y_label") + // Counterpart of iOS `.interactiveDismissDisabled`: block drag-to-hide + // and back-press, and swallow scrim-tap dismiss requests. NOTE: + // `background_interaction` has no Android counterpart here — M3's + // ModalBottomSheet is a modal window, so the scrim always intercepts + // touches; a Maps-style pane over a live background is what + // `sheet_pane` is for. + val permanent = p.getBool("permanent") if (!visible) return val theme = if (isSystemInDarkTheme()) NativeUITheme.dark else NativeUITheme.light val skipPartial = !hasPartialDetent(detentsStr) - val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = skipPartial) + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = skipPartial, + confirmValueChange = { value -> !(permanent && value == SheetValue.Hidden) }, + ) val sheetModifier = modifier .let { m -> if (a11yLabel.isNotEmpty()) m.semantics { contentDescription = a11yLabel } else m } ModalBottomSheet( onDismissRequest = { - if (onDismissCb != 0) { + if (!permanent && onDismissCb != 0) { NativeUIBridge.sendSheetDismissEvent(onDismissCb, node.id) } }, sheetState = sheetState, + properties = ModalBottomSheetProperties(shouldDismissOnBackPress = !permanent), containerColor = theme.surface, contentColor = theme.onSurface, scrimColor = BottomSheetDefaults.ScrimColor, diff --git a/resources/android/SheetPaneRenderer.kt b/resources/android/SheetPaneRenderer.kt new file mode 100644 index 0000000..dc8766e --- /dev/null +++ b/resources/android/SheetPaneRenderer.kt @@ -0,0 +1,158 @@ +package com.nativephp.plugins.native_ui.ui + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.spring +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.gestures.rememberDraggableState +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import com.nativephp.mobile.ui.nativerender.AlignItems +import com.nativephp.mobile.ui.nativerender.FlexContainer +import com.nativephp.mobile.ui.nativerender.FlexDirection +import com.nativephp.mobile.ui.nativerender.JustifyContent +import com.nativephp.mobile.ui.nativerender.NativeUIBridge +import com.nativephp.mobile.ui.nativerender.NativeUINode +import com.nativephp.plugins.native_ui.NativeUITheme +import kotlinx.coroutines.launch +import kotlin.math.abs +import kotlin.math.floor + +/** + * Inline draggable bottom pane (`sheet_pane`) — Compose counterpart of the + * iOS renderer. Bottom-anchored rounded pane that tracks vertical drags + * continuously and spring-settles to the nearest detent, seeded with the + * release velocity so flings feel continuous. The settled detent (dp) is + * reported through the element's `on_change` callback; PHP republishes it + * as the `detent` prop, which only moves the pane when the value actually + * changes. + * + * Content is laid out ONCE at the tallest detent (reveal model) so nothing + * reflows mid-drag; the animated frame clips it. Children go through + * FlexContainer so EDGE flex semantics (a scroll-view's flex-1) behave as + * in a regular column — and inner scrollables consume drags within their + * bounds, so dragging the header moves the pane while dragging a list + * scrolls it. + */ +object SheetPaneRenderer { + @Composable + fun Render(node: NativeUINode, modifier: Modifier) { + val p = node.props + val theme = if (isSystemInDarkTheme()) NativeUITheme.dark else NativeUITheme.light + val density = LocalDensity.current + + val detents = remember(p.getString("detents", "")) { + p.getString("detents", "200,560,780") + .split(",") + .mapNotNull { it.trim().toFloatOrNull() } + .sorted() + .ifEmpty { listOf(200f, 560f) } + } + val maxDetent = detents.last() + val cornerRadius = p.getFloat("corner_radius", 44f) + val insetX = p.getFloat("inset_x", 8f) + val insetBottom = p.getFloat("inset_bottom", 8f) + val detentProp = p.getFloat("detent", detents.first()) + val changeCb = p.getCallbackId("on_change") + + val height = remember { Animatable(detentProp.coerceIn(detents.first(), maxDetent)) } + val appliedDetent = remember { mutableFloatStateOf(detentProp) } + val scope = rememberCoroutineScope() + + // Only a genuinely NEW PHP-side detent moves the pane — republished + // frames with the value we just reported are ignored. + LaunchedEffect(detentProp) { + if (detentProp != appliedDetent.floatValue) { + appliedDetent.floatValue = detentProp + height.animateTo(detentProp, spring(dampingRatio = 0.8f, stiffness = 300f)) + } + } + + Box(modifier.fillMaxSize()) { + Box( + Modifier + .align(Alignment.BottomCenter) + .padding(start = insetX.dp, end = insetX.dp, bottom = insetBottom.dp) + .fillMaxWidth() + .height(height.value.dp) + .clip(RoundedCornerShape(cornerRadius.dp)) + .background(theme.background) + .draggable( + orientation = Orientation.Vertical, + state = rememberDraggableState { deltaPx -> + val deltaDp = with(density) { deltaPx.toDp().value } + val proposed = height.value - deltaDp + scope.launch { height.snapTo(rubberBand(proposed, detents)) } + }, + onDragStopped = { velocityPx -> + // Upward fling (negative y velocity) grows the pane. + val velocityDp = with(density) { -velocityPx.toDp().value } + val projected = height.value + velocityDp * 0.15f + val target = detents.minByOrNull { abs(it - projected) } ?: height.value + + val settledElsewhere = target != appliedDetent.floatValue + appliedDetent.floatValue = target + scope.launch { + height.animateTo( + target, + spring(dampingRatio = 0.8f, stiffness = 300f), + initialVelocity = velocityDp + ) + } + // Only report a detent CHANGE — snapping back to + // the detent we started from is not one. Report + // fractional detents faithfully: truncating + // `560.5` to `560` fails the applied-detent + // equality on the republish and nudges the pane + // a frame after every drag. + if (changeCb != 0 && settledElsewhere) { + val text = if (target == floor(target)) target.toInt().toString() else target.toString() + NativeUIBridge.sendTextChangeEvent(changeCb, node.id, text) + } + } + ) + ) { + // Reveal model: content at the tallest detent, clipped above. + FlexContainer( + direction = FlexDirection.COLUMN, + justify = JustifyContent.START, + align = AlignItems.STRETCH, + gap = 0f, + wrap = 0, + childNodes = node.children, + modifier = Modifier + .align(Alignment.TopStart) + .fillMaxWidth() + .height(maxDetent.dp) + ) {} + } + } + } + + /** Soft overshoot past the outermost detents instead of a hard stop. */ + private fun rubberBand(proposed: Float, detents: List): Float { + val lo = detents.first() + val hi = detents.last() + return when { + proposed > hi -> hi + (proposed - hi) * 0.25f + proposed < lo -> lo - (lo - proposed) * 0.25f + else -> proposed + } + } +} diff --git a/resources/ios/NativeUIBottomSheetRenderer.swift b/resources/ios/NativeUIBottomSheetRenderer.swift index d7b596a..3a2743f 100644 --- a/resources/ios/NativeUIBottomSheetRenderer.swift +++ b/resources/ios/NativeUIBottomSheetRenderer.swift @@ -19,6 +19,10 @@ struct NativeUIBottomSheetRenderer: View { let onDismissCb = node.props.getCallbackId("on_dismiss") let detentsStr = node.props.getString("detents", default: "medium,large") let a11yLabel = node.props.getString("a11y_label") + // Flighty/Maps-style always-on panel: no swipe-away, and the view + // behind stays interactive (HIG "sheets with interaction behind"). + let permanent = node.props.getBool("permanent") + let bgInteraction = node.props.getBool("background_interaction") Color.clear.frame(width: 0, height: 0) .sheet(isPresented: $isPresented, onDismiss: { @@ -35,6 +39,8 @@ struct NativeUIBottomSheetRenderer: View { .background(theme.surface) .presentationDetents(resolveDetents(detentsStr)) .presentationDragIndicator(.visible) + .interactiveDismissDisabled(permanent) + .presentationBackgroundInteraction(bgInteraction ? .enabled : .automatic) .modifier(A11yLabelModifier(label: a11yLabel)) } .onAppear { isPresented = visible } diff --git a/resources/ios/NativeUISheetPaneRenderer.swift b/resources/ios/NativeUISheetPaneRenderer.swift new file mode 100644 index 0000000..7e11e13 --- /dev/null +++ b/resources/ios/NativeUISheetPaneRenderer.swift @@ -0,0 +1,167 @@ +import SwiftUI + +/// Inline draggable bottom pane (`sheet_pane`) — the Maps/Flighty +/// "always-on sheet". Renders inside the screen's layer (floating chrome +/// stays above it), tracks drags continuously, and spring-snaps to the +/// nearest detent on release. The settled detent is reported to PHP via +/// the element's on_change callback; PHP re-publishes it as the `detent` +/// prop so re-renders don't move the pane. +/// +/// Height lives in a @StateObject so republished trees (poll frames) +/// never reset an in-progress position; the `detent` prop only moves the +/// pane when its value actually changes (same applied-key pattern as the +/// map camera). +struct NativeUISheetPaneRenderer: View { + let node: NativeUINode + + @StateObject private var model = SheetPaneModel() + @ObservedObject private var themeStore = NativeUITheme.shared + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + let theme = themeStore.resolve(for: colorScheme) + let p = node.props + let detents = Self.parseDetents(p.getString("detents", default: "200,560,780")) + let radius = CGFloat(p.getFloat("corner_radius", default: 44)) + let insetX = CGFloat(p.getFloat("inset_x", default: 8)) + let insetBottom = CGFloat(p.getFloat("inset_bottom", default: 8)) + let detentProp = CGFloat(p.getFloat("detent", default: Float(detents.first ?? 200))) + let changeCb = p.getCallbackId("on_change") + + GeometryReader { geo in + let paneWidth = max(0, geo.size.width - insetX * 2) + let maxDetent = detents.last ?? 780 + + // Reveal model: content is laid out ONCE at the tallest detent + // and the animated frame just uncovers more or less of it — + // real bottom-sheet behavior. Re-flowing content to the live + // height would make text and flex children shuffle mid-drag. + // + // Children go through FlexContainer (not a plain VStack) so + // EDGE flex semantics — a scroll-view's flex-1, gaps, + // alignment — behave identically to a regular . + FlexContainer( + direction: FlexDirection.column, + justify: JustifyContent.start, + align: AlignItems.stretch, + gap: 0, + wrap: 0, + childNodes: node.children + ) { + ForEach(node.children) { child in + NodeView(node: child).equatable() + } + } + .frame(width: paneWidth, height: maxDetent, alignment: .top) + .frame(width: paneWidth, height: model.height, alignment: .top) + .background(theme.background) + .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: radius, style: .continuous)) + .position( + x: geo.size.width / 2, + y: geo.size.height - insetBottom - model.height / 2 + ) + .gesture( + DragGesture(coordinateSpace: .global) + .onChanged { value in + if model.dragStartHeight == nil { + model.dragStartHeight = model.height + } + let proposed = (model.dragStartHeight ?? model.height) - value.translation.height + model.height = Self.rubberBand(proposed, detents: detents) + } + .onEnded { value in + model.dragStartHeight = nil + // Project momentum so a flick advances a detent even + // from a short travel distance. + let projected = model.height - value.predictedEndTranslation.height + value.translation.height + let target = Self.nearestDetent(to: projected, in: detents) + + // Seed the spring with the RELEASE velocity so the + // settle continues the fling instead of restarting + // from rest (a zero-velocity spring visibly hitches + // on fast swipes). initialVelocity is normalized: + // (points/sec) ÷ distance-to-target, clamped so a + // violent flick can't overshoot absurdly. + let distance = target - model.height + let released = -value.velocity.height + let initialVelocity = distance.magnitude > 1 + ? max(-25, min(25, released / distance)) + : 0 + withAnimation(.interpolatingSpring( + stiffness: 240, damping: 28, initialVelocity: initialVelocity + )) { + model.height = target + } + let settledElsewhere = target != model.appliedDetent + model.appliedDetent = target + // Only report a detent CHANGE — snapping back to the + // detent we started from is not one. Report fractional + // detents faithfully: truncating `560.5` to `560` + // fails the applied-detent equality on the republish + // and nudges the pane a frame after every drag. + if changeCb != 0 && settledElsewhere { + let text = target == target.rounded() ? String(Int(target)) : String(describing: target) + NativeElementBridge.sendTextChangeEvent(changeCb, nodeId: node.id, text: text) + } + } + ) + .onAppear { model.apply(detentProp, animated: false) } + .onChange(of: detentProp) { _, newValue in + model.apply(newValue, animated: true) + } + } + // The tab bar insets the content's safe area; a Flighty-style pane + // slides BEHIND the floating Liquid Glass bar down to the physical + // screen edge, so measure and position against the full screen. + .ignoresSafeArea(.container, edges: .bottom) + } + + private static func parseDetents(_ raw: String) -> [CGFloat] { + let parsed = raw.split(separator: ",") + .compactMap { Double($0.trimmingCharacters(in: .whitespaces)) } + .map { CGFloat($0) } + .sorted() + + return parsed.isEmpty ? [200, 560] : parsed + } + + private static func nearestDetent(to height: CGFloat, in detents: [CGFloat]) -> CGFloat { + detents.min(by: { abs($0 - height) < abs($1 - height) }) ?? height + } + + /// Clamp with a soft overshoot past the outermost detents, so pulling + /// beyond the range resists instead of hard-stopping. + private static func rubberBand(_ proposed: CGFloat, detents: [CGFloat]) -> CGFloat { + guard let lo = detents.first, let hi = detents.last else { return proposed } + if proposed > hi { + return hi + (proposed - hi) * 0.25 + } + if proposed < lo { + return lo - (lo - proposed) * 0.25 + } + return proposed + } +} + +/// Pane height that survives recomposition. `apply` early-returns when the +/// prop value hasn't changed, so republished trees never yank the pane — +/// only a genuinely new PHP-side detent moves it. +final class SheetPaneModel: ObservableObject { + @Published var height: CGFloat = 0 + var dragStartHeight: CGFloat? + var appliedDetent: CGFloat = -1 + + func apply(_ detent: CGFloat, animated: Bool) { + guard detent != appliedDetent else { return } + appliedDetent = detent + + if animated { + withAnimation(.spring(response: 0.35, dampingFraction: 0.82)) { + height = detent + } + } else { + height = detent + } + } +} diff --git a/src/Components/SheetPane.php b/src/Components/SheetPane.php new file mode 100644 index 0000000..6a7ff5e --- /dev/null +++ b/src/Components/SheetPane.php @@ -0,0 +1,13 @@ +detents($attrs['detents']); } + if (isset($attrs['permanent'])) { + $this->permanent(filter_var($attrs['permanent'], FILTER_VALIDATE_BOOLEAN)); + } + foreach (['background-interaction', 'backgroundInteraction'] as $key) { + if (isset($attrs[$key])) { + $this->backgroundInteraction(filter_var($attrs[$key], FILTER_VALIDATE_BOOLEAN)); + } + } $this->applyA11yAttributes($attrs); } @@ -59,6 +67,35 @@ public function detents(string $detents): static return $this; } + /** + * A permanent sheet can't be swiped away — drag only snaps between + * detents, and Android's back press won't dismiss it either. Pair + * with `backgroundInteraction()` on iOS so the content behind stays + * usable (see that method for the Android caveat). + */ + public function permanent(bool $value = true): static + { + $this->sheetProps['permanent'] = $value; + + return $this; + } + + /** + * Keep the view behind the sheet interactive (no dim, touches pass + * through) — the HIG "sheet with interaction behind" pattern. + * + * iOS-only: Android's Material ModalBottomSheet is a modal window, so + * the scrim always intercepts background touches. For a Maps-style + * always-on panel over a live background on both platforms, use + * `` instead. + */ + public function backgroundInteraction(bool $value = true): static + { + $this->sheetProps['background_interaction'] = $value; + + return $this; + } + public function onDismiss(string $method): static { $this->dismissCallback = $method; diff --git a/src/Elements/SheetPane.php b/src/Elements/SheetPane.php new file mode 100644 index 0000000..b892d73 --- /dev/null +++ b/src/Elements/SheetPane.php @@ -0,0 +1,143 @@ + + * ...content... + * + * + * `@change` fires when a drag settles, with the resolved detent (px) as + * the value — store it and re-publish the same number so re-renders + * don't move the pane. + */ +class SheetPane extends Element +{ + protected string $type = 'sheet_pane'; + + /** @var array */ + protected array $paneProps = []; + + protected ?string $changeCallback = null; + + public static function make(): static + { + return new static; + } + + public function applyAttributes(array $attrs): void + { + if (isset($attrs['detents'])) { + $this->detents($attrs['detents']); + } + if (isset($attrs['detent'])) { + $this->detent((float) $attrs['detent']); + } + foreach (['corner-radius', 'cornerRadius'] as $key) { + if (isset($attrs[$key])) { + $this->cornerRadius((float) $attrs[$key]); + } + } + foreach (['inset-x', 'insetX'] as $key) { + if (isset($attrs[$key])) { + $this->insetX((float) $attrs[$key]); + } + } + foreach (['inset-bottom', 'insetBottom'] as $key) { + if (isset($attrs[$key])) { + $this->insetBottom((float) $attrs[$key]); + } + } + + $this->applyA11yAttributes($attrs); + } + + /** Comma-separated resting heights in points, ascending: "200,560,780". */ + public function detents(string $detents): static + { + $this->paneProps['detents'] = $detents; + + return $this; + } + + /** Current resting height (px). Bind to the value @change reported. */ + public function detent(float $height): static + { + $this->paneProps['detent'] = $height; + + return $this; + } + + /** Match the device's display curvature: screen radius minus inset. */ + public function cornerRadius(float $radius): static + { + $this->paneProps['corner_radius'] = $radius; + + return $this; + } + + public function insetX(float $points): static + { + $this->paneProps['inset_x'] = $points; + + return $this; + } + + public function insetBottom(float $points): static + { + $this->paneProps['inset_bottom'] = $points; + + return $this; + } + + public function onChange(string $method): static + { + $this->changeCallback = $method; + + return $this; + } + + protected function defaults(): array + { + return [ + 'detents' => '200,560,780', + 'corner_radius' => 44.0, + 'inset_x' => 8.0, + 'inset_bottom' => 8.0, + ]; + } + + /** The pane paints its own chrome — no generic style wrapper. */ + public function getStyle(): array + { + return []; + } + + protected function resolveProps(CallbackRegistry $registry): array + { + $props = $this->paneProps; + + if ($this->changeCallback !== null) { + $props['on_change'] = $registry->register($this->changeCallback); + } + + return $props; + } +} diff --git a/tests/CollectorElementsTest.php b/tests/CollectorElementsTest.php index 26d752b..a36a7b4 100644 --- a/tests/CollectorElementsTest.php +++ b/tests/CollectorElementsTest.php @@ -11,6 +11,7 @@ use Native\Mobile\UI\Elements\AccordionContent; use Native\Mobile\UI\Elements\AccordionHeader; use Native\Mobile\UI\Elements\BareTextInput; +use Native\Mobile\UI\Elements\BottomSheet; use Native\Mobile\UI\Elements\Button; use Native\Mobile\UI\Elements\Checkbox; use Native\Mobile\UI\Elements\FilledTextInput; @@ -19,6 +20,7 @@ use Native\Mobile\UI\Elements\ProgressBar; use Native\Mobile\UI\Elements\Radio; use Native\Mobile\UI\Elements\RadioGroup; +use Native\Mobile\UI\Elements\SheetPane; use Native\Mobile\UI\Elements\Toggle; /** @@ -37,6 +39,8 @@ ElementRegistry::register('outlined_text_input', OutlinedTextInput::class); ElementRegistry::register('filled_text_input', FilledTextInput::class); ElementRegistry::register('toggle', Toggle::class); + ElementRegistry::register('sheet_pane', SheetPane::class); + ElementRegistry::register('bottom_sheet', BottomSheet::class); ElementRegistry::register('checkbox', Checkbox::class); ElementRegistry::register('progress_bar', ProgressBar::class); ElementRegistry::register('radio_group', RadioGroup::class); @@ -407,3 +411,76 @@ expect($programmaticRegistry->resolve($programmaticButtons[0]['props']['on_press']))->toBe(['method' => 'decrement', 'args' => []]); expect($programmaticRegistry->resolve($programmaticButtons[1]['props']['on_press']))->toBe(['method' => 'increment', 'args' => []]); }); + +it('applies sheet pane props with kebab attributes and registers the change callback', function () { + NativeElementCollector::leaf('sheet_pane', [ + 'detents' => '180,520', + 'detent' => '520', + 'corner-radius' => '32', + 'inset-x' => '12', + 'inset-bottom' => '16', + '_change' => 'onDetentChange', + ]); + + $registry = new CallbackRegistry; + $tree = NativeElementCollector::collect()->toArray($registry); + + expect($tree['type'])->toBe('sheet_pane'); + expect($tree['props']['detents'])->toBe('180,520'); + expect($tree['props']['detent'])->toBe(520.0); + expect($tree['props']['corner_radius'])->toBe(32.0); + expect($tree['props']['inset_x'])->toBe(12.0); + expect($tree['props']['inset_bottom'])->toBe(16.0); + expect($registry->resolve($tree['props']['on_change']))->toBe(['method' => 'onDetentChange', 'args' => []]); +}); + +it('accepts camelCase sheet pane attributes', function () { + NativeElementCollector::leaf('sheet_pane', [ + 'cornerRadius' => '28', + 'insetX' => '4', + 'insetBottom' => '6', + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['corner_radius'])->toBe(28.0); + expect($tree['props']['inset_x'])->toBe(4.0); + expect($tree['props']['inset_bottom'])->toBe(6.0); +}); + +it('falls back to the sheet pane defaults when attributes are absent', function () { + NativeElementCollector::leaf('sheet_pane', []); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['detents'])->toBe('200,560,780'); + expect($tree['props']['corner_radius'])->toBe(44.0); + expect($tree['props']['inset_x'])->toBe(8.0); + expect($tree['props']['inset_bottom'])->toBe(8.0); +}); + +it('applies the permanent and background-interaction sheet props', function () { + NativeElementCollector::leaf('bottom_sheet', [ + 'visible' => true, + 'permanent' => true, + 'background-interaction' => true, + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['type'])->toBe('bottom_sheet'); + expect($tree['props']['permanent'])->toBeTrue(); + expect($tree['props']['background_interaction'])->toBeTrue(); +}); + +it('parses string booleans on the new sheet props via filter_var', function () { + NativeElementCollector::leaf('bottom_sheet', [ + 'permanent' => 'false', + 'backgroundInteraction' => 'false', + ]); + + $tree = NativeElementCollector::collect()->toArray(new CallbackRegistry); + + expect($tree['props']['permanent'])->toBeFalse(); + expect($tree['props']['background_interaction'])->toBeFalse(); +});