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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<native:date-picker
label="Due"
placeholder="Optional"
native:model="dueOn"
clearable
clear-label="{{ __('Clear') }}"
/>
```

`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
Expand Down
143 changes: 92 additions & 51 deletions resources/android/DatePickerRenderer.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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,
),
)
}
}

Expand Down
4 changes: 4 additions & 0 deletions resources/boost/guidelines/core.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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()`.
Expand Down
34 changes: 33 additions & 1 deletion resources/ios/NativeUIDatePickerRenderer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand All @@ -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 {
Expand Down Expand Up @@ -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).
Expand Down
33 changes: 33 additions & 0 deletions src/Elements/DatePicker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}
Expand Down Expand Up @@ -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 ─────────────────────────────────────────────────

/**
Expand Down
20 changes: 20 additions & 0 deletions tests/DatePickerRendererTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

test('draws the iOS X only beside a picked compact date', function () {
$renderer = file_get_contents(dirname(__DIR__).'/resources/ios/NativeUIDatePickerRenderer.swift');

expect($renderer)
->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: ""\)/');
});
Loading
Loading