Add a clear button to the date picker

`clearable` empties a date, or both ends of a range, closes an open
picker and hands focus back to the field, as the time picker's does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V9NnLxnPp8vaaurb3Z1MFy
This commit is contained in:
Andreas Reinhold / reini
2026-09-13 09:30:26 +02:00
co-authored by Claude Opus 5
parent eca6b8fb05
commit 23218431bf
6 changed files with 57 additions and 5 deletions
@@ -523,7 +523,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
```blade
<x-datepicker label="Expires on" wire:model.live="expiresOn" :min="now()" :max="now()->addMonth()" />
<x-datepicker label="Birthday" mode="modal" wire:model="birthday" :max="now()" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" />
<x-datepicker label="Trip" range wire:model="trip" hint="Start and end" clearable />
```
| Prop | Default | |
@@ -534,6 +534,7 @@ M3 date pickers on a text field. `wire:model` stores `Y-m-d` strings (`x-model`
| `label`, `hint`, `icon`, `variant`, `size` | | the field's |
| `value` | `null` | the initial value without `wire:model` |
| `name` | | adds hidden inputs with `Y-m-d` for a plain form post (`name[start]`, `name[end]` for a range) |
| `clearable` | `false` | a button that empties the field (both ends of a range) once it holds a date |
Picking in the calendar is a draft; OK or Enter on a day keeps it, Cancel or Escape does not. A typed date is the value once it is whole and allowed; otherwise the field says why. Month and weekday names, the week's first day and the typed format follow `app()->getLocale()`. Keyboard: arrows, Home/End (week), PageUp/PageDown (month; with Shift, year), Space, Enter, Escape. `min` and `max` are read when the picker starts: when they change on the server, give the component a `wire:key` that changes with them. `required`, `disabled` and `readonly` reach the text field.
+12
View File
@@ -366,6 +366,18 @@ document.addEventListener('alpine:init', () => {
}
},
/** The clear button: no date (no start and no end), closed, and focus back in the field. */
clear() {
if (this.open) {
this.cancel(false)
}
this.fieldError = ''
this.text = ''
this.write(null)
this.$refs.input.focus()
},
fieldProblem(value) {
if (!config.range) {
return this.problem(value)
@@ -21,7 +21,8 @@
with, and the errors for `period`, `period.start` and `period.end` all belong to this field.
`min` and `max` (`Y-m-d` or a date object) disable the days outside them and keep the
keyboard inside them. `label`, `hint`, `icon`, `variant` (`outlined`, `filled`) and `size`
are the field's; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
are the field's; `clearable` adds a button that empties it (a date, or both ends of a range)
once it holds one; `name` adds hidden inputs carrying `Y-m-d` for a plain form post, and every
other attribute (`required`, `disabled`, `readonly`) reaches the text field. Errors under
the `wire:model` name replace the hint, and so does a typed date that cannot be read.
@@ -48,6 +49,7 @@
'min' => null,
'max' => null,
'value' => null,
'clearable' => false,
])
@php
@@ -179,6 +181,19 @@
/>
<x-slot:trailing>
@if ($clearable)
<button
type="button"
class="field-trailing field-clear field-button"
aria-label="{{ __('Clear') }}"
x-on:click="clear()"
@disabled($attributes->get('disabled') || $attributes->get('readonly'))
data-field-clear
>
<x-livewire-material::icon name="close" class="size-(--field-icon)" />
</button>
@endif
<button
type="button"
class="field-trailing field-button"
@@ -3,7 +3,7 @@
'docked' => <<<'BLADE'
<div class="grid w-full gap-6 md:grid-cols-2" x-data="{ expires: '{{ now()->addWeek()->format('Y-m-d') }}', starts: null }">
<div class="grid content-start gap-4">
<x-datepicker label="Expires on" x-model="expires" hint="Type a date or pick one" />
<x-datepicker label="Expires on" clearable x-model="expires" hint="Type a date or pick one" />
<p class="type-body-sm text-on-surface-variant">Bound value: <code x-text="JSON.stringify(expires)"></code></p>
</div>
+17 -2
View File
@@ -33,10 +33,10 @@ class DateProbe extends Component
<p>trip: <span id="trip">{{ json_encode($trip) }}</span></p>
<p>renders: <span id="renders">{{ $renders }}</span></p>
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" />
<x-datepicker id="expires-field" label="Expires" wire:model.live="expires" min="2026-09-10" max="2026-10-20" clearable />
<x-datepicker id="birthday-field" label="Birthday" mode="modal" wire:model.live="birthday" />
<x-datepicker id="delivery-field" label="Delivery" mode="input" wire:model.live="delivery" min="2026-01-01" />
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" />
<x-datepicker id="trip-field" label="Trip" range wire:model.live="trip" clearable />
</div>
BLADE;
}
@@ -296,3 +296,18 @@ it('opens a docked picker as a modal one on a compact window', function () {
->assertScript("document.querySelector('#expires-field-picker').matches(':modal')")
->assertScript("getComputedStyle(document.querySelector('#expires-field-picker [data-datepicker-header]')).display !== 'none'");
});
it('empties a date, or both ends of a range, with its clear button', function () {
$page = dateProbe()
->assertScript("getComputedStyle(document.querySelector('#expires-field').closest('.field').querySelector('[data-field-clear]')).display !== 'none'");
$page->click('[data-datepicker]:has(#expires-field) [data-field-clear]')
->assertScript("document.querySelector('#expires').textContent === ''")
->assertValue('#expires-field', '')
->assertScript("document.activeElement.id === 'expires-field'")
->assertScript("getComputedStyle(document.querySelector('[data-datepicker]:has(#expires-field) [data-field-clear]')).display === 'none'");
$page->click('[data-datepicker]:has(#trip-field) [data-field-clear]')
->assertSeeIn('#trip', '{"start":null,"end":null}')
->assertValue('#trip-field', '');
});
@@ -163,3 +163,12 @@ it('replaces the hint with the errors for the property and its start and end', f
->toContain('data-datepicker-error')
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
});
it('offers a clear button on request', function () {
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
->toContain('data-field-clear')
->toContain('x-on:click="clear()"')
->toContain('aria-label="Clear"')
->and((string) $this->blade('<x-datepicker label="Expires" />'))
->not->toContain('data-field-clear');
});