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(), + ) +}