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
37 changes: 36 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ One component covers every Flatpickr mode you need:
|------|--------|----------------|
| Date | `Flatpickr::make('date')` | `Y-m-d` |
| Date & time | `->time(true)` or `->timePicker()` | `Y-m-d H:i` / `H:i` |
| Range | `->rangePicker()` | array of date strings |
| Range | `->rangePicker()` | array of date strings, or two fields with `->rangeEnd()` |
| Multiple dates | `->multiplePicker()` | array of date strings |
| Week | `->weekPicker()` | `W Y` |
| Month | `->monthPicker()` | `Y-m` |
Expand All @@ -80,6 +80,7 @@ Flatpickr::make('week_number')->weekPicker()->format('W Y');
Flatpickr::make('month')->monthPicker()->format('Y-m')->displayFormat('F Y');
Flatpickr::make('year')->yearPicker();
Flatpickr::make('range')->rangePicker();
Flatpickr::make('starts_at')->rangePicker()->rangeEnd('ends_at')->format('Y-m-d');
Flatpickr::make('occupied_slots')->multiplePicker()->format('Y-m-d')->displayFormat('F j, Y');
```

Expand Down Expand Up @@ -130,6 +131,40 @@ See the [Flatpickr documentation](https://flatpickr.js.org/options/) for details
|--------|------------------|
| Date, time, week, month, year | `string` or `CarbonInterface` |
| Range, multiple | `array` of date strings or `CarbonInterface` instances |
| Range with `->rangeEnd('ends_at')` | Two separate fields: start and end strings or `CarbonInterface` instances |

### Split range across two fields

When your model uses separate `starts_at` and `ends_at` columns, bind the range picker to the start field and name the end field explicitly. One picker is shown; both attributes are hydrated, synced live, and dehydrated on save.

```php
Flatpickr::make('starts_at')
->label('Event dates')
->rangePicker()
->rangeEnd('ends_at')
->format('Y-m-d');
```

Do not add a second Flatpickr on `ends_at`. Validation rules on `ends_at` (for example `after:starts_at`) still work because the end value is kept in sync while the user selects a range.

#### Date & time range

Use `->time(true)` with a format that includes hours and minutes. `displayFormat()` controls what the user sees in the input (Flatpickr tokens, not PHP `date()` tokens). Storage and dehydration still use `format()`.

```php
Flatpickr::make('starts_at')
->label('Event schedule')
->rangePicker()
->rangeEnd('ends_at')
->time(true)
->format('Y-m-d H:i') // saved to starts_at / ends_at
->displayFormat('M j, Y h:i K') // e.g. Jun 14, 2024 7:00 AM to Jun 17, 2024 5:00 PM
->rangeSeparator(' to ');
```

Ensure your model casts both columns as `datetime`. The picker UI lets you choose a date and time for each end of the range in one calendar.

See [RFC 0001](rfcs/0001-split-range-end-field.md) for the full design.

## Themes

Expand Down
4 changes: 2 additions & 2 deletions resources/dist/components/flatpickr.js

Large diffs are not rendered by default.

122 changes: 120 additions & 2 deletions resources/js/components/flatpickr.js
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,39 @@ function parseConstraintValue(value) {
return value
}

function isRangeMode(attrs) {
return attrs.mode === 'range' || attrs.rangePicker === true
}

function sortSelectedDates(selectedDates) {
return [...selectedDates].sort((first, second) => first.getTime() - second.getTime())
}

function formatSelectedDates(fp, selectedDates, attrs) {
if (!selectedDates?.length || !fp) {
return null
}

const dates =
isRangeMode(attrs) && selectedDates.length >= 2
? sortSelectedDates(selectedDates)
: selectedDates

const format = fp.config.dateFormat ?? attrs.dateFormat ?? 'Y-m-d'
const separator = isRangeMode(attrs)
? (fp.l10n?.rangeSeparator ?? attrs.rangeSeparator ?? ' to ')
: (attrs.conjunction ?? ', ')

return dates.map((date) => fp.formatDate(date, format)).join(separator)
}

function setRangeDatesOnPicker(fp, dates, triggerChange = false) {
fp.selectedDates = dates.filter(Boolean)
fp.latestSelectedDateObj = fp.selectedDates[fp.selectedDates.length - 1] ?? undefined
fp.redraw()
fp.updateValue(triggerChange)
}

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

Expand All @@ -82,11 +115,16 @@ export default function flatpickrComponent(state, attrs) {

fp: null,
visibilityObserver: null,
isPickerUpdate: false,

init: function () {
this.initWhenVisible()

this.$watch('state', (value) => {
if (this.isPickerUpdate) {
return
}

this.syncPickerFromState(value)
})

Expand Down Expand Up @@ -132,6 +170,24 @@ export default function flatpickrComponent(state, attrs) {
this.visibilityObserver.observe(this.$el)
},

setPickerState: function (selectedDates) {
if (isRangeMode(this.attrs) && selectedDates.length >= 2) {
selectedDates = sortSelectedDates(selectedDates)

if (this.fp) {
setRangeDatesOnPicker(this.fp, selectedDates)
}
}

this.isPickerUpdate = true
this.state =
selectedDates.length === 0 ? null : formatSelectedDates(this.fp, selectedDates, this.attrs)

this.$nextTick(() => {
this.isPickerUpdate = false
})
},

initFlatpickr: function () {
if (this.fp) {
this.fp.destroy()
Expand Down Expand Up @@ -186,10 +242,16 @@ export default function flatpickrComponent(state, attrs) {
return
}

this.state = selectedDates.length === 0 ? null : dateStr
if (selectedDates.length === 0) {
this.setPickerState([])

return
}

this.setPickerState(selectedDates)
},
onClose: () => {
this.commitManualInput()
this.syncStateFromPicker()
},
}

Expand Down Expand Up @@ -219,11 +281,31 @@ export default function flatpickrComponent(state, attrs) {
})
},

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

if (isRangeMode(this.attrs)) {
this.setPickerState(this.fp.selectedDates)

return
}

this.commitManualInput()
},

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

if (isRangeMode(this.attrs)) {
this.setPickerState(this.fp.selectedDates)

return
}

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

if (inputValue === '') {
Expand Down Expand Up @@ -267,6 +349,42 @@ export default function flatpickrComponent(state, attrs) {
return
}

if (isRangeMode(this.attrs) && this.attrs.enableTime && typeof normalized === 'string') {
const separator = this.fp.l10n?.rangeSeparator ?? this.attrs.rangeSeparator ?? ' to '

if (normalized.includes(separator)) {
const parts = normalized.split(separator).map((part) => part.trim())

if (parts.length === 2) {
const format = this.fp.config.dateFormat
const dates = parts
.map((part) => this.fp.parseDate(part, format))
.filter(Boolean)

if (dates.length === 2) {
const sortedDates = sortSelectedDates(dates)

setRangeDatesOnPicker(this.fp, sortedDates)

const sortedState = formatSelectedDates(this.fp, sortedDates, this.attrs)

if (sortedState !== normalized) {
this.isPickerUpdate = true
this.state = sortedState

this.$nextTick(() => {
this.isPickerUpdate = false
})

return
}

return
}
}
}
}

this.fp.setDate(normalized, false)
},
}
Expand Down
155 changes: 155 additions & 0 deletions rfcs/0001-split-range-end-field.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,155 @@
# RFC 0001: Split range picker across two form fields

| Field | Value |
|-------|-------|
| **Status** | Implemented |
| **Author** | @coolsam726 |
| **Created** | 2026-06-13 |
| **Implemented in** | [#153](https://github.com/coolsam726/flatpickr/pull/153) |

## Summary

Add an optional `rangeEnd()` API so a single range Flatpickr field can read and write two separate model attributes (for example `starts_at` and `ends_at`) instead of storing an array on one field.

## Motivation

Today, `->rangePicker()` binds to one state path and dehydrates to an **array of two date strings**:

```php
Flatpickr::make('booking_period')
->rangePicker()
->format('Y-m-d');

// Saved state: ['2024-06-01', '2024-06-15']
```

That works for JSON columns or virtual attributes, but most Laravel models use **separate columns** for start and end dates. Developers then either:

- store JSON in one column (poor for querying/indexing), or
- manually split/join range state in form callbacks (repetitive and error-prone).

We want one visible range picker while persisting to two first-class attributes with no boilerplate.

## Goals

- Keep the existing single-field array behaviour as the **default** (no breaking change).
- Allow mapping a range picker to a second sibling state path via a fluent API.
- Hydrate from two DB values when editing; dehydrate back to two values on save.
- Keep the end field updated **live** so validation and reactive fields on `ends_at` work before save.
- Require **no JavaScript changes** — Flatpickr still manages one combined range string internally.

## Non-goals

- Two visible Flatpickr inputs for one range.
- Automatic creation of a hidden `ends_at` field in the schema (developers own their model/fillable attributes).
- Range-end splitting for `multiplePicker()` (out of scope for this RFC).

## Proposed API

```php
Flatpickr::make('starts_at')
->label('Event dates')
->rangePicker()
->rangeEnd('ends_at')
->format('Y-m-d');
```

| Method | Description |
|--------|-------------|
| `rangeEnd(string \| Closure \| null $field)` | Relative state path of the end date field (sibling by default). Supports closures for dynamic paths. |
| `getRangeEndField()` | Resolved end field name. |
| `getRangeEndStatePath()` | Absolute state path used during dehydration. |
| `hasRangeEndField()` | Whether split dehydration is active. |

### Behaviour

#### Livewire state (while editing)

| Path | Value |
|------|-------|
| `starts_at` | Combined range string for the picker, e.g. `2024-06-01 to 2024-06-15` |
| `ends_at` | End date only, synced via `afterStateUpdated` |

The picker remains entangled to `starts_at`. The combined string is required so Flatpickr can render the selection.

#### Hydration (load record)

When both `starts_at` and `ends_at` are filled and `starts_at` is **not** already a combined range string, merge them into one range display value for the picker.

When `starts_at` already contains the configured `rangeSeparator`, treat it as a combined range (legacy / single-column data).

#### Dehydration (save)

Override `getStateToDehydrate()` to return **two** keys:

```php
[
'starts_at' => '2024-06-01',
'ends_at' => '2024-06-15',
]
```

Uses existing `dehydrateFlatpickr()` parsing (separator, regex, brute-force split).

Blank picker state clears both paths.

#### Date & time ranges

Combine `->time(true)` with a datetime `format()`. Use `displayFormat()` for human-readable input text (Flatpickr format tokens). Split dehydration via `rangeEnd()` works the same way:

```php
Flatpickr::make('starts_at')
->rangePicker()
->rangeEnd('ends_at')
->time(true)
->format('Y-m-d H:i')
->displayFormat('M j, Y h:i K')
->rangeSeparator(' to ');
```

#### Validation

Existing range validation on the primary field still validates the combined / array value. Rules on `ends_at` (e.g. `after:starts_at`) work because the end field is synced live.

## Alternatives considered

### 1. Two separate date fields with linked min/max

```php
Flatpickr::make('starts_at')->maxDate(fn (Get $get) => $get('ends_at')),
Flatpickr::make('ends_at')->minDate(fn (Get $get) => $get('starts_at')),
```

**Rejected:** Two pickers, poor UX for selecting a range, no shared calendar range selection.

### 2. Custom `afterStateUpdated` in every resource

**Rejected:** Repetitive; easy to forget hydration on edit.

### 3. Store JSON array on one column

**Rejected:** Already supported by default range picker; doesn't solve separate-column models.

### 4. `rangePicker(end: 'ends_at')` parameter

**Rejected for now:** Prefer explicit `->rangeEnd()` chain for readability and optional Closure support.

## Drawbacks

- Developers must **not** add a second visible Flatpickr on `ends_at` (would conflict).
- Live form state temporarily holds a combined string on `starts_at` until save; code reading `$record->starts_at` mid-request should use dehydrated data.
- Nested paths rely on Filament's relative state path resolution; deeply nested schemas need careful field naming.

## Implementation plan

- [x] Add `$rangeEndField` property and fluent `rangeEnd()` method.
- [x] Override `getStateToDehydrate()` for split dehydration.
- [x] Extend `afterStateHydrated()` to merge separate DB values on load.
- [x] Add `afterStateUpdated()` + `syncRangeEndField()` for live end sync.
- [x] Pest tests in `FlatpickrRangeEndTest.php`.
- [x] README documentation.
- [x] Close this RFC as **Implemented** (PR linked below).

## Open questions

_None — RFC closed; implemented as described above._
Loading
Loading