Skip to content
Merged
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
4 changes: 2 additions & 2 deletions resources/dist/components/flatpickr.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion resources/dist/themes/default.css

Large diffs are not rendered by default.

143 changes: 124 additions & 19 deletions resources/js/components/flatpickr.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,14 @@ function normalizeState(state) {
return state
}

function parseConstraintValue(value) {
if (value === null || value === undefined || value === '') {
return null
}

return value
}

export default function flatpickrComponent(state, attrs) {
const timezone = dayjs.tz.guess()

Expand All @@ -69,13 +77,55 @@ export default function flatpickrComponent(state, attrs) {
timezone,

fp: null,
visibilityObserver: null,

init: function () {
this.initFlatpickr()
this.initWhenVisible()

this.$watch('state', (value) => {
this.syncPickerFromState(value)
})

this.registerModalListeners()
},

registerModalListeners: function () {
const initIfNeeded = () => {
if (!this.fp && this.isElementVisible()) {
this.initFlatpickr()
}
}

;['modal-opened', 'ax-modal-opened', 'opened-form-component-action-modal'].forEach((eventName) => {
window.addEventListener(eventName, initIfNeeded)
})
},

isElementVisible: function () {
return this.$el && this.$el.offsetParent !== null
},

initWhenVisible: function () {
if (this.isElementVisible()) {
this.initFlatpickr()

return
}

if (typeof IntersectionObserver === 'undefined') {
return
}

this.visibilityObserver = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting && !this.fp) {
this.initFlatpickr()
this.visibilityObserver?.disconnect()
}
})
})

this.visibilityObserver.observe(this.$el)
},

initFlatpickr: function () {
Expand All @@ -100,38 +150,42 @@ export default function flatpickrComponent(state, attrs) {
plugins.push(new WeekSelect({}))
}

const minDate = parseConstraintValue(this.$refs.minDate?.value ?? this.attrs.minDate)
const maxDate = parseConstraintValue(this.$refs.maxDate?.value ?? this.attrs.maxDate)

let disabledDates = this.attrs.disable ?? []

if (this.$refs.disabledDates?.value) {
try {
disabledDates = JSON.parse(this.$refs.disabledDates.value) ?? disabledDates
} catch (error) {
disabledDates = this.attrs.disable ?? []
}
}

const config = {
disableMobile: true,
initialDate: normalizeState(this.state),
defaultDate: normalizeState(this.state),
static: false,
altInput: true,
...this.attrs,
minDate,
maxDate,
disable: disabledDates,
locale: customLocale,
plugins,
onChange: (selectedDates, dateStr) => {
this.state = selectedDates.length === 0 ? null : dateStr
},
onClose: () => {
if (!this.fp || !this.attrs.allowInput) {
return
}

const inputValue = this.fp.altInput?.value ?? this.fp.input.value

if (inputValue === '') {
this.fp.clear()
this.state = null
if (this.attrs.yearPicker && selectedDates.length > 0) {
this.state = String(selectedDates[0].getFullYear())

return
}

const parsed = this.fp.parseDate(inputValue, this.fp.config.dateFormat)

if (parsed) {
this.fp.setDate(parsed, false)
this.state = this.fp.input.value
}
this.state = selectedDates.length === 0 ? null : dateStr
},
onClose: () => {
this.commitManualInput()
},
}

Expand All @@ -140,11 +194,62 @@ export default function flatpickrComponent(state, attrs) {

this.fp = flatpickr(this.$refs.input, config)

this.bindManualInputEvents()

if (this.state) {
this.syncPickerFromState(this.state)
}
},

bindManualInputEvents: function () {
if (!this.fp) {
return
}

const inputs = [this.fp.altInput, this.fp.input].filter(Boolean)

inputs.forEach((input) => {
input.addEventListener('blur', () => {
this.commitManualInput()
})
})
},

commitManualInput: function () {
if (!this.fp) {
return
}

const inputValue = this.fp.altInput?.value ?? this.fp.input.value

if (inputValue === '') {
this.fp.clear()
this.state = null

return
}

if (!this.attrs.allowInput && !this.attrs.timePicker) {
return
}

const parsed = this.fp.parseDate(inputValue, this.fp.config.dateFormat)

if (!parsed) {
return
}

this.fp.setDate(parsed, false)

if (this.attrs.yearPicker) {
this.state = String(parsed.getFullYear())

return
}

this.state = this.fp.input.value
},

syncPickerFromState: function (value) {
if (!this.fp) {
return
Expand Down
32 changes: 29 additions & 3 deletions resources/views/forms/components/flatpickr.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,13 @@
$hasTime = $hasTime();
$id = $getId();
$isDisabled = $isDisabled();
$isReadOnly = $isReadOnly();
$hasDate = $hasDate();
$isPrefixInline = $isPrefixInline();
$isSuffixInline = $isSuffixInline();
$maxDate = $getMaxDate();
$minDate = $getMinDate();
$disabledDates = $getDisableDates();
$prefixActions = $getPrefixActions();
$prefixIcon = $getPrefixIcon();
$prefixLabel = $getPrefixLabel();
Expand All @@ -18,6 +21,7 @@
$suffixLabel = $getSuffixLabel();
$statePath = $getStatePath();
$attrs = $getFlatpickrAttributes();
$livewireKey = method_exists($field, 'getLivewireKey') ? $field->getLivewireKey() : $id;

@endphp

Expand Down Expand Up @@ -69,11 +73,23 @@
@else
<div
wire:ignore
wire:key="{{ $livewireKey }}.{{
substr(md5(serialize([
$disabledDates,
$isDisabled,
$isReadOnly,
$maxDate,
$minDate,
$hasTime,
$hasDate,
$attrs,
])), 0, 64)
}}"
@if (FilamentView::hasSpaMode())
{{-- format-ignore-start --}}x-load="visible || event (ax-modal-opened)"
{{-- format-ignore-start --}}x-load="visible || event (ax-modal-opened) || event (modal-opened)"
{{-- format-ignore-end --}}
@else
x-load
x-load="visible || event (modal-opened) || event (opened-form-component-action-modal)"
@endif
x-load-css="[
@js(\Filament\Support\Facades\FilamentAsset::getStyleHref('flatpickr-styles', \Coolsam\Flatpickr\FilamentFlatpickr::getPackageName()))
Expand All @@ -87,6 +103,16 @@
->class(['fi-fo-date-time-picker'])
}}
>
<input x-ref="minDate" type="hidden" value="{{ $minDate }}" />

<input x-ref="maxDate" type="hidden" value="{{ $maxDate }}" />

<input
x-ref="disabledDates"
type="hidden"
value="{{ json_encode($disabledDates) }}"
/>

<x-filament::input
:attributes="
\Filament\Support\prepare_inherited_attributes($getExtraInputAttributeBag())
Expand Down Expand Up @@ -120,4 +146,4 @@
@endforeach
</datalist>
@endif
</x-dynamic-component>
</x-dynamic-component>
27 changes: 27 additions & 0 deletions src/Forms/Components/Flatpickr.php
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ class Flatpickr extends Field

protected bool | Closure $monthPicker = false;

protected bool | Closure $yearPicker = false;

protected bool | Closure $rangePicker = false;

protected bool | Closure $multiplePicker = false;
Expand Down Expand Up @@ -172,6 +174,10 @@ public function getFormat(): string
return $format;
}

if ($this->isYearPicker()) {
return 'Y';
}

if ($this->isTimePicker() || ($this->hasTime() && ! $this->hasDate())) {
return $this->hasSeconds() ? 'H:i:S' : 'H:i';
}
Expand Down Expand Up @@ -992,6 +998,13 @@ public function monthPicker(Closure | bool $monthPicker = true): Flatpickr
return $this;
}

public function yearPicker(Closure | bool $yearPicker = true): Flatpickr
{
$this->yearPicker = $yearPicker;

return $this;
}

public function rangePicker(Closure | bool $rangePicker = true): Flatpickr
{
$this->rangePicker = $rangePicker;
Expand Down Expand Up @@ -1205,6 +1218,11 @@ public function isMonthPicker(): bool
return FilamentFlatpickr::getBool($this->evaluate($this->monthPicker));
}

public function isYearPicker(): bool
{
return FilamentFlatpickr::getBool($this->evaluate($this->yearPicker));
}

public function isRangePicker(): bool
{
return FilamentFlatpickr::getBool($this->evaluate($this->rangePicker) || $this->getMode()->value === FlatpickrMode::RANGE->value);
Expand Down Expand Up @@ -1335,6 +1353,15 @@ public function getFlatpickrAttributes(): array
if (filled($this->isMonthPicker())) {
$attrs->put('monthPicker', FilamentFlatpickr::getBool($this->isMonthPicker()));
}
if (filled($this->isYearPicker())) {
$isYearPicker = FilamentFlatpickr::getBool($this->isYearPicker());
$attrs->put('yearPicker', $isYearPicker);

if ($isYearPicker) {
$attrs->put('dateFormat', 'Y');
$attrs->put('altFormat', $this->getDisplayFormat() ?? 'Y');
}
}
if (filled($this->getLocale())) {
$attrs->put('locale', $this->getLocale());
}
Expand Down
18 changes: 18 additions & 0 deletions tests/FlatpickrDehydrationTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,24 @@
->toBeNull();
});

it('defaults year picker format to a four-digit year', function () {
$component = Flatpickr::make('year')->yearPicker();

expect($component->getFormat())->toBe('Y')
->and($component->isYearPicker())->toBeTrue()
->and($component->getFlatpickrAttributes()['yearPicker'])->toBeTrue()
->and($component->getFlatpickrAttributes()['dateFormat'])->toBe('Y');
});

it('dehydrates year picker values as a year string', function () {
$component = Flatpickr::make('year')->yearPicker();

$result = Flatpickr::dehydrateFlatpickr($component, '2024');

expect($result)->toBeInstanceOf(CarbonInterface::class)
->and($result->format('Y'))->toBe('2024');
});

it('parses carbon instances for time-only dehydration output', function () {
$component = Flatpickr::make('start_time')
->timePicker()
Expand Down
Loading