diff --git a/README.md b/README.md
index de5f10a..740a540 100644
--- a/README.md
+++ b/README.md
@@ -400,6 +400,28 @@ element.)
| `inline` | `.graphical` — embedded calendar | embedded picker |
| `wheel` | `.wheel` — drum | **no drum in Material**; falls back to embedded |
+### Clearing a picked date
+
+`clearable` draws an X inside a compact picker while it holds a value — beside
+the calendar icon on Android, beside the date button on iOS. Tapping it sends
+`''` through `@change` / `native:model`, the same empty value that clears the
+picker from PHP, and leaves the dialog closed.
+
+```blade
+
+```
+
+`clear-label` is what a screen reader says for the X (`Clear` when unset), so
+pass a translated string. Inline and wheel pickers always show a date, so they
+draw no X. On iOS a cleared picker falls back to its placeholder chip, so give
+a clearable picker a `placeholder`.
+
### Platform notes
- `title`, `confirm-label`, and `cancel-label` are **Android only** — iOS
diff --git a/resources/android/DatePickerRenderer.kt b/resources/android/DatePickerRenderer.kt
index f6911da..13bb1bd 100644
--- a/resources/android/DatePickerRenderer.kt
+++ b/resources/android/DatePickerRenderer.kt
@@ -1,15 +1,19 @@
package com.nativephp.plugins.native_ui.ui
-import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDefaults
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.IconButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.SelectableDates
@@ -25,7 +29,12 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.focusProperties
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.onClick
+import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
import com.nativephp.mobile.ui.MaterialIcon
@@ -81,6 +90,8 @@ object DatePickerRenderer {
val title = p.getString("title")
val confirmLabel = p.getString("confirm_label", "OK")
val cancelLabel = p.getString("cancel_label", "Cancel")
+ val clearable = p.getBool("clearable")
+ val clearLabel = p.getString("clear_label", "Clear")
val minRaw = p.getString("min")
val maxRaw = p.getString("max")
val hourFormat = p.getString("hour_format", "auto")
@@ -152,58 +163,88 @@ object DatePickerRenderer {
onCommit = commit,
)
} else {
+ val openDialog = {
+ // The trigger consumes the tap, so drop the keyboard
+ // here unless the focused field keeps focus
+ // (mobile-air #335).
+ KeyboardFocusPolicy.dismissForInteractiveTap()
+ pendingDate = null
+ showDialog = true
+ }
// Trigger. An OutlinedTextField can't take clicks while
- // readOnly, so a transparent sibling Box in the same layout
- // slot captures them — keeps the field's normal (non-disabled)
- // colors, which `enabled = false` would grey out.
- Box {
- OutlinedTextField(
- value = shown,
- onValueChange = {},
- readOnly = true,
- enabled = !disabled,
- isError = isError,
- supportingText = supportingSlot(supporting),
- placeholder = if (placeholder.isNotEmpty()) {
- { Text(placeholder, fontFamily = nuiDefaultFontFamily()) }
- } else null,
- trailingIcon = {
- MaterialIcon(
- name = if (mode == "time") "clock" else "calendar",
- contentDescription = null,
- tint = theme.onSurfaceVariant,
- )
- },
- modifier = Modifier.fillMaxWidth(),
- textStyle = TextStyle(color = theme.onSurface),
- colors = OutlinedTextFieldDefaults.colors(
- errorBorderColor = theme.destructive,
- errorSupportingTextColor = theme.destructive,
- errorTrailingIconColor = theme.destructive,
- focusedTextColor = theme.onSurface,
- unfocusedTextColor = theme.onSurface,
- focusedBorderColor = theme.primary,
- unfocusedBorderColor = theme.outline,
- disabledBorderColor = theme.outline.copy(alpha = 0.5f),
- focusedPlaceholderColor = theme.onSurfaceVariant,
- unfocusedPlaceholderColor = theme.onSurfaceVariant,
- focusedTrailingIconColor = theme.onSurfaceVariant,
- unfocusedTrailingIconColor = theme.onSurfaceVariant,
- ),
- )
- Box(
- Modifier
- .matchParentSize()
- .clickable(enabled = !disabled) {
- // The trigger consumes the tap, so drop the
- // keyboard here unless the focused field
- // keeps focus (mobile-air #335).
- KeyboardFocusPolicy.dismissForInteractiveTap()
- pendingDate = null
- showDialog = true
- }
- )
+ // readOnly, so the tap is read off its own interaction source
+ // rather than a sibling laid over it, which would swallow the
+ // clear button in the trailing slot too. A tap on that button
+ // is consumed by the button and never presses the field.
+ val interactionSource = remember { MutableInteractionSource() }
+ LaunchedEffect(interactionSource) {
+ interactionSource.interactions.collect { interaction ->
+ if (interaction is PressInteraction.Release) openDialog()
+ }
}
+ val showClear = clearable && value.isNotEmpty() && !disabled
+
+ OutlinedTextField(
+ value = shown,
+ onValueChange = {},
+ readOnly = true,
+ enabled = !disabled,
+ interactionSource = interactionSource,
+ isError = isError,
+ supportingText = supportingSlot(supporting),
+ placeholder = if (placeholder.isNotEmpty()) {
+ { Text(placeholder, fontFamily = nuiDefaultFontFamily()) }
+ } else null,
+ trailingIcon = {
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ if (showClear) {
+ IconButton(
+ onClick = { commit("") },
+ modifier = Modifier.semantics { contentDescription = clearLabel },
+ ) {
+ MaterialIcon(
+ name = "cancel",
+ contentDescription = null,
+ size = 20.dp,
+ tint = theme.onSurfaceVariant,
+ )
+ }
+ }
+ // Sized like the IconButton beside it, so the icon
+ // keeps the inset it has alone in the slot.
+ Box(Modifier.size(48.dp), contentAlignment = Alignment.Center) {
+ MaterialIcon(
+ name = if (mode == "time") "clock" else "calendar",
+ contentDescription = null,
+ tint = theme.onSurfaceVariant,
+ )
+ }
+ }
+ },
+ modifier = Modifier
+ .fillMaxWidth()
+ // Never focused, so a tap leaves the field in its
+ // resting colors.
+ .focusProperties { canFocus = false }
+ // TalkBack's click goes through semantics, not the
+ // interaction source.
+ .then(if (disabled) Modifier else Modifier.semantics { onClick { openDialog(); true } }),
+ textStyle = TextStyle(color = theme.onSurface),
+ colors = OutlinedTextFieldDefaults.colors(
+ errorBorderColor = theme.destructive,
+ errorSupportingTextColor = theme.destructive,
+ errorTrailingIconColor = theme.destructive,
+ focusedTextColor = theme.onSurface,
+ unfocusedTextColor = theme.onSurface,
+ focusedBorderColor = theme.primary,
+ unfocusedBorderColor = theme.outline,
+ disabledBorderColor = theme.outline.copy(alpha = 0.5f),
+ focusedPlaceholderColor = theme.onSurfaceVariant,
+ unfocusedPlaceholderColor = theme.onSurfaceVariant,
+ focusedTrailingIconColor = theme.onSurfaceVariant,
+ unfocusedTrailingIconColor = theme.onSurfaceVariant,
+ ),
+ )
}
}
diff --git a/resources/boost/guidelines/core.blade.php b/resources/boost/guidelines/core.blade.php
index f4fccd5..e5751c7 100644
--- a/resources/boost/guidelines/core.blade.php
+++ b/resources/boost/guidelines/core.blade.php
@@ -75,6 +75,10 @@
translated strings. `min`/`max` are NOT supported for `mode="time"` (they
throw), and sync-mode modifiers (`native:model.blur`) throw too — a picker
always commits on selection.
+- Add `clearable` for the X inside a compact picker that holds a value. Tapping
+ it sends `''` to `@change` / `native:model`, so don't hand-roll a Clear
+ button beside the picker; pass a translated `clear-label` for its
+ screen-reader label.
- In tests, drive pickers with the `pickDate()` / `pickTime()` /
`pickDateTime()` / `clearPicker()` macros and assert with
`assertPicker()` / `assertPickerValue()` / `assertPickerEmpty()`.
diff --git a/resources/ios/NativeUIDatePickerRenderer.swift b/resources/ios/NativeUIDatePickerRenderer.swift
index 13f4fb6..9918946 100644
--- a/resources/ios/NativeUIDatePickerRenderer.swift
+++ b/resources/ios/NativeUIDatePickerRenderer.swift
@@ -45,6 +45,8 @@ struct NativeUIDatePickerRenderer: View {
let maxRaw = p.getString("max")
let onChangeCb = p.getCallbackId("on_change")
let disabled = p.getBool("disabled")
+ let clearable = p.getBool("clearable")
+ let clearLabel = p.getString("clear_label", default: "Clear")
let a11yLabel = p.getString("a11y_label")
let a11yHint = p.getString("a11y_hint")
let isError = p.getBool("is_error")
@@ -94,7 +96,7 @@ struct NativeUIDatePickerRenderer: View {
.disabled(disabled)
.opacity(disabled ? 0.6 : 1.0)
} else {
- picker(
+ let styledPicker = picker(
mode: mode,
minDate: minDate,
maxDate: maxDate
@@ -112,6 +114,23 @@ struct NativeUIDatePickerRenderer: View {
// VoiceOver reads the formatted selection as the control's
// value, so a bare a11y-label still announces what's picked.
.accessibilityValue(displayString(mode: mode, locale: locale, calendar: calendar))
+
+ // The compact button shows the picked date with no calendar
+ // icon, so the X sits right beside it.
+ if clearable && hasSelection && display == "compact" && !disabled {
+ HStack(spacing: 8) {
+ styledPicker
+ Button {
+ clear(onChangeCb: onChangeCb)
+ } label: {
+ Image(systemName: "xmark.circle.fill")
+ .foregroundStyle(theme.onSurfaceVariant)
+ }
+ .accessibilityLabel(clearLabel)
+ }
+ } else {
+ styledPicker
+ }
}
if !supporting.isEmpty {
@@ -196,6 +215,19 @@ struct NativeUIDatePickerRenderer: View {
}
}
+ /// Drop the selection and dispatch the empty wire value. `selected` goes
+ /// back to now, so the placeholder chip adopts the current instant again
+ /// on its next tap, as it does before any pick.
+ private func clear(onChangeCb: Int) {
+ hasSelection = false
+ selected = Date()
+ lastSentValue = ""
+
+ if onChangeCb != 0 {
+ NativeElementBridge.sendSelectChangeEvent(onChangeCb, nodeId: node.id, value: "")
+ }
+ }
+
/// Wall-clock string → instant, via `calendar`. Returns nil for an empty
/// or malformed value (PHP validates before serializing, so malformed
/// here means a hand-built prop).
diff --git a/src/Elements/DatePicker.php b/src/Elements/DatePicker.php
index 3713f42..c198424 100644
--- a/src/Elements/DatePicker.php
+++ b/src/Elements/DatePicker.php
@@ -144,6 +144,15 @@ public function applyAttributes(array $attrs): void
$this->cancelLabel((string) $cancelLabel);
}
+ if (! empty($attrs['clearable'])) {
+ $this->clearable();
+ }
+
+ $clearLabel = $attrs['clear-label'] ?? $attrs['clearLabel'] ?? null;
+ if ($clearLabel !== null) {
+ $this->clearLabel((string) $clearLabel);
+ }
+
if (! empty($attrs['disabled'])) {
$this->disabled();
}
@@ -301,6 +310,30 @@ public function cancelLabel(string $text): static
return $this;
}
+ /**
+ * Draw an X inside the compact trigger while a value is picked. Tapping
+ * it fires the change callback with `''` — the same "nothing chosen"
+ * the wire uses for `value('')` — and leaves the dialog closed. Inline
+ * and wheel pickers always show a date, so they draw no X.
+ */
+ public function clearable(bool $value = true): static
+ {
+ $this->pickerProps['clearable'] = $value;
+
+ return $this;
+ }
+
+ /**
+ * The X's accessibility label, `Clear` when unset. A screen reader
+ * speaks it, so pass a translated string: `->clearLabel(__('Clear'))`.
+ */
+ public function clearLabel(string $text): static
+ {
+ $this->pickerProps['clear_label'] = $text;
+
+ return $this;
+ }
+
// ── Internationalization ─────────────────────────────────────────────────
/**
diff --git a/tests/DatePickerRendererTest.php b/tests/DatePickerRendererTest.php
new file mode 100644
index 0000000..52f2ee1
--- /dev/null
+++ b/tests/DatePickerRendererTest.php
@@ -0,0 +1,20 @@
+toBeString()
+ ->toContain('let clearable = p.getBool("clearable")')
+ ->toMatch('/if clearable && hasSelection && display == "compact" && !disabled \{\s*HStack\(spacing: 8\) \{\s*styledPicker\s*Button \{\s*clear\(onChangeCb: onChangeCb\)/')
+ ->toMatch('/Image\(systemName: "xmark\.circle\.fill"\).*?\.accessibilityLabel\(clearLabel\)/s');
+});
+
+test('clears an iOS picker to the empty wire value', function () {
+ $renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIDatePickerRenderer.swift');
+
+ expect($renderer)
+ ->toBeString()
+ ->toMatch('/private func clear\(onChangeCb: Int\) \{\s*hasSelection = false\s*selected = Date\(\)\s*lastSentValue = ""\s*'
+ .'if onChangeCb != 0 \{\s*NativeElementBridge\.sendSelectChangeEvent\(onChangeCb, nodeId: node\.id, value: ""\)/');
+});
diff --git a/tests/DatePickerTest.php b/tests/DatePickerTest.php
index 48c2f86..f544d75 100644
--- a/tests/DatePickerTest.php
+++ b/tests/DatePickerTest.php
@@ -197,6 +197,19 @@ function pickerPropsFromAttrs(array $attrs): array
expect($props['value'])->toBe('2026-07-25');
});
+// ── Clearing ─────────────────────────────────────────────────────────────────
+
+it('serializes a clearable picker with the label of its X', function () {
+ $props = pickerProps(DatePicker::make()->clearable()->clearLabel('Remove date'));
+
+ expect($props['clearable'])->toBeTrue();
+ expect($props['clear_label'])->toBe('Remove date');
+});
+
+it('leaves a picker without an X unless it asks for one', function () {
+ expect(pickerProps(DatePicker::make()))->not->toHaveKeys(['clearable', 'clear_label']);
+});
+
// ── Blade attributes ─────────────────────────────────────────────────────────
it('applies kebab-case attributes from Blade', function () {
@@ -211,6 +224,8 @@ function pickerPropsFromAttrs(array $attrs): array
'hour-format' => '24',
'confirm-label' => 'Übernehmen',
'cancel-label' => 'Abbrechen',
+ 'clearable' => true,
+ 'clear-label' => 'Löschen',
'title' => 'Termin wählen',
'label' => 'Termin',
'placeholder' => 'Datum wählen',
@@ -227,6 +242,8 @@ function pickerPropsFromAttrs(array $attrs): array
expect($props['hour_format'])->toBe('24');
expect($props['confirm_label'])->toBe('Übernehmen');
expect($props['cancel_label'])->toBe('Abbrechen');
+ expect($props['clearable'])->toBeTrue();
+ expect($props['clear_label'])->toBe('Löschen');
expect($props['title'])->toBe('Termin wählen');
expect($props['label'])->toBe('Termin');
expect($props['placeholder'])->toBe('Datum wählen');
@@ -238,11 +255,13 @@ function pickerPropsFromAttrs(array $attrs): array
'hourFormat' => '12',
'confirmLabel' => 'Done',
'cancelLabel' => 'Dismiss',
+ 'clearLabel' => 'Remove date',
]);
expect($props['hour_format'])->toBe('12');
expect($props['confirm_label'])->toBe('Done');
expect($props['cancel_label'])->toBe('Dismiss');
+ expect($props['clear_label'])->toBe('Remove date');
});
// ── Accessibility ────────────────────────────────────────────────────────────
diff --git a/tests/android/README.md b/tests/android/README.md
index b2beeb7..92bdb2d 100644
--- a/tests/android/README.md
+++ b/tests/android/README.md
@@ -2,7 +2,8 @@
This standalone Robolectric/Compose module compiles the production filled and
outlined editors, their shared dispatcher, the production Button renderer and
-the production gesture area, list, virtual list and pager renderers.
+the production gesture area, list, virtual list, pager and date picker
+renderers.
It records the bridge event boundary; it does not load PHP/JNI, render icons or
menus, or prove device IME behaviour. The Mobile policy and node models are read
from an explicit checkout, not substituted by test implementations.
@@ -48,4 +49,9 @@ the list doesn't repeat an applied request, disabling then restoring an index
re-arms it, and saved-state restoration keeps the reader's position unless the
request changed while it was saved. Assertions check which row is at the
viewport's top, not just that a scroll was invoked.
+
+`DatePickerClearTest` taps the X of a clearable compact picker and checks it
+sends `''` with the dialog left closed, that a tap elsewhere on the field and
+an accessibility click both open the dialog, and that no X is drawn without a
+value, without `clearable` or on a disabled picker.
These are JVM checks; SwiftUI and native device behaviour need separate testing.
diff --git a/tests/android/build.gradle.kts b/tests/android/build.gradle.kts
index 05c4fc7..1323f2d 100644
--- a/tests/android/build.gradle.kts
+++ b/tests/android/build.gradle.kts
@@ -5,7 +5,7 @@ plugins {
}
val stageSources = tasks.register("stageSources") {
from(layout.projectDirectory.dir("../../resources/android")) {
- include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "BareTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt", "ListRenderer.kt", "VirtualListRenderer.kt", "PagerRenderer.kt")
+ include("FilledTextInputRenderer.kt", "OutlinedTextInputRenderer.kt", "BareTextInputRenderer.kt", "TextInputShared.kt", "NativeUIFontResolver.kt", "NativeUITheme.kt", "ButtonRenderer.kt", "GestureAreaRenderer.kt", "ListRenderer.kt", "VirtualListRenderer.kt", "PagerRenderer.kt", "DatePickerRenderer.kt")
}
from(providers.gradleProperty("mobileRoot").map { "$it/resources/androidstudio/app/src/main/java/com/nativephp/mobile/ui/nativerender" }) {
include("KeyboardFocusPolicy.kt", "NativeUINode.kt", "SharedValueStore.kt")
diff --git a/tests/android/src/main/kotlin/Bridge.kt b/tests/android/src/main/kotlin/Bridge.kt
index 73d040d..44eaadc 100644
--- a/tests/android/src/main/kotlin/Bridge.kt
+++ b/tests/android/src/main/kotlin/Bridge.kt
@@ -13,5 +13,6 @@ object NativeUIBridge {
fun sendPressEvent(callbackId: Int, nodeId: Int) { events += "press:$callbackId" }
fun sendSliderChangeEvent(callbackId: Int, nodeId: Int, value: Float) { events += "slider:$callbackId:$value" }
fun sendTabChangeEvent(callbackId: Int, nodeId: Int, index: Int) { events += "tab:$callbackId:$index" }
+ fun sendSelectChangeEvent(callbackId: Int, nodeId: Int, value: String) { events += "select:$callbackId:$value" }
}
fun argbToComposeColor(argb: Int) = Color(argb)
diff --git a/tests/android/src/test/kotlin/DatePickerClearTest.kt b/tests/android/src/test/kotlin/DatePickerClearTest.kt
new file mode 100644
index 0000000..d8b7607
--- /dev/null
+++ b/tests/android/src/test/kotlin/DatePickerClearTest.kt
@@ -0,0 +1,89 @@
+package com.nativephp.plugins.native_ui.ui
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.test.*
+import androidx.compose.ui.test.junit4.createComposeRule
+import com.nativephp.mobile.ui.nativerender.*
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+/**
+ * A clearable compact picker draws an X beside its calendar icon while it
+ * holds a date. Tapping the X sends `''` through the change callback and
+ * leaves the dialog closed; tapping the rest of the field, or clicking it
+ * through TalkBack, opens the dialog.
+ */
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [35])
+class DatePickerClearTest {
+ @get:Rule val compose = createComposeRule()
+
+ @After fun reset() {
+ NativeUIBridge.events.clear()
+ }
+
+ @Test fun tappingTheXClearsTheDateAndLeavesTheDialogClosed() {
+ render(picker(1, "value" to "2026-11-06", "clearable" to true))
+
+ compose.onNodeWithContentDescription("Clear").performClick()
+
+ compose.runOnIdle { assertEquals(listOf("select:5:"), NativeUIBridge.events) }
+ compose.onNodeWithText("Done").assertDoesNotExist()
+ compose.onNodeWithContentDescription("Clear").assertDoesNotExist()
+ }
+
+ @Test fun tappingTheFieldOpensTheDialog() {
+ render(picker(1, "value" to "2026-11-06", "clearable" to true))
+
+ compose.onNodeWithText("Nov 6, 2026").performTouchInput { click(centerLeft + percentOffset(0.25f, 0f)) }
+
+ compose.onNodeWithText("Done").assertExists()
+ compose.runOnIdle { assertEquals(emptyList(), NativeUIBridge.events) }
+ }
+
+ @Test fun clickingTheFieldThroughAccessibilityOpensTheDialog() {
+ render(picker(1, "value" to "2026-11-06", "clearable" to true))
+
+ compose.onNodeWithText("Nov 6, 2026").performSemanticsAction(SemanticsActions.OnClick)
+
+ compose.onNodeWithText("Done").assertExists()
+ }
+
+ @Test fun drawsNoXWithoutADateClearableOrAnEnabledField() {
+ render(
+ picker(1, "clearable" to true),
+ picker(2, "value" to "2026-11-06"),
+ picker(3, "value" to "2026-11-06", "clearable" to true, "disabled" to true),
+ )
+
+ compose.onNodeWithContentDescription("Clear").assertDoesNotExist()
+ }
+
+ @Test fun labelsTheXWithTheClearLabel() {
+ render(picker(1, "value" to "2026-11-06", "clearable" to true, "clear_label" to "Remove date"))
+
+ compose.onNodeWithContentDescription("Remove date").assertExists()
+ }
+
+ private fun render(vararg pickers: NativeUINode) {
+ compose.setContent {
+ MaterialTheme {
+ Column { pickers.forEach { DatePickerRenderer.Render(it, Modifier) } }
+ }
+ }
+ }
+
+ private fun picker(id: Int, vararg props: Pair) = NativeUINode(
+ id, "", null, null,
+ GenericProps(mapOf("locale" to "en-US", "on_change" to 5, "confirm_label" to "Done") + props),
+ 0, 0, emptyList(),
+ )
+}