Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions resources/android/BottomSheetRenderer.kt
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,15 @@ package com.nativephp.plugins.native_ui.ui
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.BottomSheetDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.ModalBottomSheetProperties
import androidx.compose.material3.SheetValue
import androidx.compose.material3.Text
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
Expand All @@ -16,8 +20,10 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.nativerender.NativeUIBridge
import com.nativephp.mobile.ui.nativerender.NativeUINode
import com.nativephp.mobile.ui.nativerender.RenderNode
Expand All @@ -43,6 +49,8 @@ import com.nativephp.plugins.native_ui.NativeUITheme
* content's height, capped below the status bar. M3's partial stop sits
* at half the screen whatever the content, so the other detents listed
* beside `fit` have nothing to snap to and are ignored.
*
* A `title` is Material heading text under the drag handle (`SheetHeading`).
*/
object BottomSheetRenderer {
@OptIn(ExperimentalMaterial3Api::class)
Expand All @@ -60,6 +68,7 @@ object BottomSheetRenderer {
// touches; a Maps-style pane over a live background is what
// `sheet_pane` is for.
val permanent = p.getBool("permanent")
val title = p.getString("title")

val fits = hasDetent(detentsStr, "fit")
val skipPartial = fits || !hasPartialDetent(detentsStr)
Expand Down Expand Up @@ -109,12 +118,33 @@ object BottomSheetRenderer {
// current detent's height, leaving drag-room to the next one.
// A fit sheet wants exactly that content-sized default.
Column(modifier = if (fits) sheetModifier else sheetModifier.fillMaxHeight()) {
if (title.isNotEmpty()) {
SheetHeading(title = title, color = theme.onSurface)
}
node.children.forEach { RenderNode(it) }
}
}
}
}

/**
* A titled sheet's heading under the drag handle. Material's modal bottom
* sheet has no top bar or close button — it closes on a swipe down, a tap
* on the scrim or back — so the title is plain heading text.
*/
@Composable
private fun SheetHeading(title: String, color: Color) {
Text(
text = title,
modifier = Modifier
.fillMaxWidth()
.padding(start = 20.dp, end = 20.dp, bottom = 8.dp),
style = MaterialTheme.typography.titleLarge,
fontFamily = nuiDefaultFontFamily(),
color = color,
)
}

/**
* Returns true iff the detents string requests at least one partial stop.
*
Expand Down
2 changes: 1 addition & 1 deletion resources/boost/skills/nativephp-mobile-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ dropped without an error.
| `icon` | `name` (`star.fill`, `trash`, `plus`), or per platform `ios="checkmark.circle.fill" android="check_circle"`, `:size="20"`, `class="text-theme-primary"` |
| `top-bar` | `title`, `subtitle`, `back`. Put it at the top level of the view |
| `emoji-picker` | `:visible`, `@change` (gets the emoji), `@dismiss`, `done-label`. The system picker: iOS emoji keyboard, Android `EmojiPickerView` in a sheet. Stays open until `visible` is false |
| `bottom-sheet` | `:visible`, `@dismiss`, `detents` small\|medium\|large\|full\|fit or a fraction (`0.4`), comma-separated (`fit,large`); `fit` sizes the sheet to its content. `permanent`, `background-interaction` (iOS) |
| `bottom-sheet` | `:visible`, `@dismiss`, `detents` small\|medium\|large\|full\|fit or a fraction (`0.4`), comma-separated (`fit,large`); `fit` sizes the sheet to its content. `title`: iOS shows it in the sheet's own bar with the system close button (sends `@dismiss`), Android as heading text under the handle; don't draw your own title row. `close-label` (the close button's VoiceOver label), `permanent`, `background-interaction` (iOS) |
| `sheet-pane` | Inline pane drawn inside the screen that drags between `detents`: heights in points or `fit` (the content's height), comma-separated (`88,fit`). `detent` (a number or `fit`) and `@change` (gets the settled detent, `fit` included); store it and bind it back to `detent`. `corner-radius`, `inset-x`, `inset-bottom` |
| also | `toggle`, `checkbox`, `slider`, `select`, `radio-group`, `chip`, `badge`, `date-picker`, `modal`, `tab-row`, `progress-bar`, `activity-indicator` |

Expand Down
81 changes: 80 additions & 1 deletion resources/ios/NativeUIBottomSheetRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import SwiftUI
/// SwiftUI's `.sheet` with `presentationDetents` so system drag-to-dismiss
/// and detent snapping come for free.
///
/// A `title` gives the sheet the system's own bar, as Apple's sheets have
/// (`SheetTitleBar`). An untitled sheet is the bare panel.
///
/// Model 3: container color from `theme.surface`. No per-instance override.
struct NativeUIBottomSheetRenderer: View {
let node: NativeUINode
Expand All @@ -13,6 +16,9 @@ struct NativeUIBottomSheetRenderer: View {

@State private var isPresented: Bool = false
@State private var fitHeight: CGFloat?
// A titled fit sheet is as tall as its content and its bar together.
@State private var contentHeight: CGFloat = 0
@State private var barHeight: CGFloat = 0
@State private var selectedDetent: PresentationDetent = .medium

var body: some View {
Expand All @@ -26,6 +32,8 @@ struct NativeUIBottomSheetRenderer: View {
// behind stays interactive (HIG "sheets with interaction behind").
let permanent = node.props.getBool("permanent")
let bgInteraction = node.props.getBool("background_interaction")
let title = node.props.getString("title")
let closeLabel = node.props.getString("close_label")

Color.clear.frame(width: 0, height: 0)
.sheet(isPresented: $isPresented, onDismiss: {
Expand All @@ -45,10 +53,22 @@ struct NativeUIBottomSheetRenderer: View {
}
.fixedSize(horizontal: false, vertical: fits)
.onGeometryChange(for: CGFloat.self) { $0.size.height } action: { height in
if fits, height > 0 { fitHeight = height }
contentHeight = height
if fits, height > 0 { fitHeight = height + barHeight }
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.background(theme.surface)
.modifier(SheetTitleBar(
title: title,
closeLabel: closeLabel,
closable: !permanent,
background: theme.surface,
close: { isPresented = false },
measured: { height in
barHeight = height
if fits, contentHeight > 0 { fitHeight = contentHeight + height }
}
))
}
.onAppear { NativeUIKeyboardAccessoryState.shared.sheetDepth += 1 }
.onDisappear { NativeUIKeyboardAccessoryState.shared.sheetDepth -= 1 }
Expand Down Expand Up @@ -118,6 +138,65 @@ private struct DetentsModifier: ViewModifier {
}
}

/// A titled sheet is drawn as Apple's own sheets are (HIG, Sheets): its
/// content sits in a NavigationStack whose inline bar carries the title and
/// the system's close button on its leading edge, and the bar keeps the
/// content clear of the grabber. Closing is the user's dismissal, so it goes
/// through `isPresented` and onDismiss reports it as `@dismiss`, like a
/// swipe-down. `measured` hands over the bar's height, which a fit sheet
/// adds to its content's.
private struct SheetTitleBar: ViewModifier {
let title: String
let closeLabel: String
let closable: Bool
let background: Color
let close: () -> Void
let measured: (CGFloat) -> Void

func body(content: Content) -> some View {
if title.isEmpty {
content
} else {
NavigationStack {
content
.background {
// Reaches up under the bar, so its top inset IS the bar.
background
.ignoresSafeArea()
.onGeometryChange(for: CGFloat.self) { $0.safeAreaInsets.top } action: { measured($0) }
}
.navigationTitle(title)
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
if closable {
ToolbarItem(placement: .cancellationAction) { closeButton }
}
}
}
}
}

/// The system's close button: `role: .close` on iOS 26, the filled
/// circle sheets used before it. `close_label` replaces the system's
/// VoiceOver label, so an app can pass its own translation.
@ViewBuilder
private var closeButton: some View {
if #available(iOS 26.0, *) {
Button(role: .close, action: close)
.modifier(A11yLabelModifier(label: closeLabel))
} else {
Button(action: close) {
Image(systemName: "xmark.circle.fill")
.symbolRenderingMode(.hierarchical)
.font(.title2)
.foregroundStyle(.secondary)
}
.accessibilityLabel(closeLabel.isEmpty ? "Close" : closeLabel)
}
}
}

private struct A11yLabelModifier: ViewModifier {
let label: String
func body(content: Content) -> some View {
Expand Down
42 changes: 42 additions & 0 deletions src/Elements/BottomSheet.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,15 @@
* Bottom sheet — dismissible panel that slides up from the bottom. Visibility
* driven by `visible`; `@dismiss` fires on drag-down or tap-outside.
*
* A `title` draws the sheet the way each platform designs its own. iOS
* puts the content in a navigation stack whose inline bar carries the
* title and the system's close button (Apple's HIG, Sheets); the close
* button reports `@dismiss` like a swipe-down. Android shows the title as
* Material heading text under the drag handle, with no close button:
* Material's sheet closes on a swipe down, a tap outside or back.
*
* <native:bottom-sheet :visible="$open" title="Filters" @dismiss="close">
*
* Model 3: container colors from theme. No per-instance `backgroundColor`
* override — wrap content in `<column class="bg-...">` if a
* non-standard surface is truly needed, but prefer the theme.
Expand Down Expand Up @@ -43,6 +52,14 @@ public function applyAttributes(array $attrs): void
$this->backgroundInteraction(filter_var($attrs[$key], FILTER_VALIDATE_BOOLEAN));
}
}
if (isset($attrs['title'])) {
$this->title((string) $attrs['title']);
}
foreach (['close-label', 'closeLabel'] as $key) {
if (isset($attrs[$key])) {
$this->closeLabel((string) $attrs[$key]);
}
}

$this->applyA11yAttributes($attrs);
}
Expand Down Expand Up @@ -103,6 +120,31 @@ public function backgroundInteraction(bool $value = true): static
return $this;
}

/**
* The sheet's title. iOS shows it in the sheet's own navigation bar,
* beside the system's close button (left out on a `permanent` sheet);
* Android shows it as heading text under the drag handle. An empty
* title leaves the sheet untitled.
*/
public function title(string $title): static
{
$this->sheetProps['title'] = $title;

return $this;
}

/**
* What VoiceOver reads for the close button of a titled iOS sheet. The
* system's own label when unset; pass your app's translation of
* "Close" if the system's language may differ from your app's.
*/
public function closeLabel(string $label): static
{
$this->sheetProps['close_label'] = $label;

return $this;
}

public function onDismiss(string $method): static
{
$this->dismissCallback = $method;
Expand Down
50 changes: 49 additions & 1 deletion tests/BottomSheetRendererTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,55 @@
->toMatch('/height\.map \{ \.height\(\$0\) \}/')
->toMatch('/\.fixedSize\(horizontal: false, vertical: fits\)\s*'
.'\.onGeometryChange\(for: CGFloat\.self\) \{ \$0\.size\.height \} action: \{ height in\s*'
.'if fits, height > 0 \{ fitHeight = height \}/s');
.'contentHeight = height\s*'
.'if fits, height > 0 \{ fitHeight = height \+ barHeight \}/s');
});

test('puts a titled iOS sheet in a navigation stack with its title inline', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift');

expect($renderer)
->toBeString()
->toMatch('/let title = node\.props\.getString\("title"\)/')
->toMatch('/\.background\(theme\.surface\)\s*\.modifier\(SheetTitleBar\(\s*title: title,/s')
->toMatch('/if title\.isEmpty \{\s*content\s*\} else \{\s*NavigationStack \{\s*content/s')
->toMatch('/\.navigationTitle\(title\)\s*\.navigationBarTitleDisplayMode\(\.inline\)/s');
});

test('closes a titled iOS sheet with the system close button, as the user\'s dismissal', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift');

expect($renderer)
->toBeString()
->toMatch('/closable: !permanent,/')
->toMatch('/close: \{ isPresented = false \},/')
->toMatch('/if closable \{\s*ToolbarItem\(placement: \.cancellationAction\) \{ closeButton \}/s')
->toMatch('/if #available\(iOS 26\.0, \*\) \{\s*Button\(role: \.close, action: close\)\s*'
.'\.modifier\(A11yLabelModifier\(label: closeLabel\)\)/s')
->toMatch('/Image\(systemName: "xmark\.circle\.fill"\)/');
});

test('makes a titled iOS fit sheet as tall as its content and its bar', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIBottomSheetRenderer.swift');

expect($renderer)
->toBeString()
->toMatch('/@State private var barHeight: CGFloat = 0/')
->toMatch('/measured: \{ height in\s*barHeight = height\s*'
.'if fits, contentHeight > 0 \{ fitHeight = contentHeight \+ height \}/s')
->toMatch('/\.onGeometryChange\(for: CGFloat\.self\) \{ \$0\.safeAreaInsets\.top \} action: \{ measured\(\$0\) \}/');
});

test('shows a titled Android sheet\'s title as heading text above its content', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/android/BottomSheetRenderer.kt');

expect($renderer)
->toBeString()
->toMatch('/val title = p\.getString\("title"\)/')
->toMatch('/if \(title\.isNotEmpty\(\)\) \{\s*SheetHeading\(title = title, color = theme\.onSurface\)\s*\}\s*'
.'node\.children\.forEach \{ RenderNode\(it\) \}/s')
->toMatch('/private fun SheetHeading\(title: String, color: Color\) \{.*?'
.'style = MaterialTheme\.typography\.titleLarge/s');
});

test('keeps an iOS fit sheet on its fit detent when the content height changes', function () {
Expand Down
32 changes: 32 additions & 0 deletions tests/CollectorElementsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -557,6 +557,38 @@
expect($tree['props']['background_interaction'])->toBeFalse();
});

it('applies the sheet title and close label', function () {
NativeElementCollector::leaf('bottom_sheet', [
'title' => 'Filters',
'close-label' => 'Sulje',
]);

$tree = NativeElementCollector::collect()->toArray(new CallbackRegistry);

expect($tree['props']['title'])->toBe('Filters');
expect($tree['props']['close_label'])->toBe('Sulje');
});

it('accepts the camelCase close label and the fluent sheet title', function () {
NativeElementCollector::leaf('bottom_sheet', ['closeLabel' => 'Fermer']);

expect(NativeElementCollector::collect()->toArray(new CallbackRegistry)['props']['close_label'])->toBe('Fermer');

$props = BottomSheet::make()->title('Filters')->closeLabel('Close')->toArray(new CallbackRegistry)['props'];

expect($props['title'])->toBe('Filters');
expect($props['close_label'])->toBe('Close');
});

it('leaves a sheet untitled when no title is given', function () {
NativeElementCollector::leaf('bottom_sheet', ['visible' => true]);

$props = NativeElementCollector::collect()->toArray(new CallbackRegistry)['props'];

expect($props)->not->toHaveKey('title');
expect($props)->not->toHaveKey('close_label');
});

// ── autocapitalize (mobile-air #304) ─────────────────────────────────────────

it('serializes autocapitalization via both fluent APIs', function () {
Expand Down
Loading