Add dateFormat patterns and boundInput to Datepicker

This commit is contained in:
codecalm committed 2026-10-01 00:26:10 +02:00
1 parent 4f31a3bcc4
commit 1b4ae2c330
5 files changed
+249 -23

No files matched your search

+6
View File
@@ -0,0 +1,6 @@
---
"@tabler/core": minor
"@tabler/docs": patch
---
Added `dateFormat` patterns such as `iso` or `DD.MM.YYYY` and a `boundInput` option for ISO form values to `Datepicker`.
+111 -17
View File
@@ -39,6 +39,8 @@ const SELECTOR_BOUND_INPUT = 'input[type="hidden"], input[name]'
const HIDE_DELAY = 100 // ms delay before hiding after selection
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
const FORMAT_TOKENS = /YYYY|MM|M|DD|D/g
const ISO_FORMAT = 'YYYY-MM-DD'
// Vanilla Calendar Pro 3.4 moved several months, the time picker, week
// numbers, popups and animation into extensions that must be registered when
@@ -84,8 +86,13 @@ type ComponentConfig = {
datepickerTheme: string | null
dateMin: DateAny | null
dateMax: DateAny | null
/** `Intl.DateTimeFormat` options, or a function(date, locale) returning the text shown in the field */
dateFormat: Intl.DateTimeFormatOptions | ((date: Date, locale: string | undefined) => string) | null
/** input that receives the picked dates as `YYYY-MM-DD`, comma-separated; an inline calendar finds a hidden one inside */
boundInput: string | HTMLInputElement | null
/**
* How the date is written into the field: a pattern such as `'DD.MM.YYYY'` (tokens `YYYY`, `MM`, `M`, `DD`, `D`),
* `'iso'`, `Intl.DateTimeFormat` options, or a function(date, locale) returning the text
*/
dateFormat: string | Intl.DateTimeFormatOptions | ((date: Date, locale: string | undefined) => string) | null
/** element that shows the formatted date; a button uses itself or its `[data-bs-datepicker-display]` child */
displayElement: string | HTMLElement | boolean | null
displayMonthsCount: MonthsCount
@@ -111,6 +118,7 @@ type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
type ChangeEventArgs = { dates: string[]; event: MouseEvent }
const Default: ComponentConfig = {
boundInput: null,
datepickerTheme: null,
dateMin: null,
dateMax: null,
@@ -129,10 +137,11 @@ const Default: ComponentConfig = {
}
const DefaultType: Record<keyof ComponentConfig, string> = {
boundInput: '(null|string|element)',
datepickerTheme: '(null|string)',
dateMin: '(null|string|number|object|date)',
dateMax: '(null|string|number|object|date)',
dateFormat: '(null|object|function)',
dateFormat: '(null|string|object|function)',
displayElement: '(null|string|element|boolean)',
displayMonthsCount: 'number',
firstWeekday: 'number',
@@ -146,6 +155,43 @@ const DefaultType: Record<keyof ComponentConfig, string> = {
vcpOptions: 'object',
}
const pad = (value: number, length = 2): string => String(value).padStart(length, '0')
const formatPattern = (date: Date, pattern: string): string => {
const parts: Record<string, string> = {
YYYY: pad(date.getFullYear(), 4),
MM: pad(date.getMonth() + 1),
M: String(date.getMonth() + 1),
DD: pad(date.getDate()),
D: String(date.getDate()),
}
return pattern.replace(FORMAT_TOKENS, (token) => parts[token]!)
}
// Reads text written with `pattern` back into `YYYY-MM-DD`, or null when it
// does not match or names a day that does not exist.
const parsePattern = (value: string, pattern: string): string | null => {
const order: string[] = []
const source = pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&').replace(FORMAT_TOKENS, (token) => {
order.push(token[0]!)
return token === 'YYYY' ? '(\\d{4})' : token.length === 2 ? '(\\d{2})' : '(\\d{1,2})'
})
const match = new RegExp(`^${source}$`).exec(value)
if (!match) {
return null
}
const parts: Record<string, number> = { Y: 0, M: 1, D: 1 }
order.forEach((key, index) => {
parts[key] = Number(match[index + 1])
})
const { Y: year = 0, M: month = 1, D: day = 1 } = parts
const date = new Date(year, month - 1, day)
return date.getMonth() === month - 1 && date.getDate() === day ? `${pad(year, 4)}-${pad(month)}-${pad(day)}` : null
}
// Delegated Data API handlers run in the capture phase, before the plugin's
// own click listener, which opens the popup from a `setTimeout`. Two nested
// timeouts are the first point that is after it for certain.
@@ -180,6 +226,7 @@ class Datepicker extends BaseComponent {
_isInput = false
_isInline = false
_boundInput: HTMLInputElement | null = null
_boundInputInside = false
_positionElement: HTMLElement | null = null
_displayElement: HTMLElement | false | null = null
_themeObserver: MutationObserver | null = null
@@ -304,7 +351,7 @@ class Datepicker extends BaseComponent {
if (clone && clone !== this._element && clone.isConnected) {
if (this._isInput) {
clone.replaceWith(this._element)
} else if (this._boundInput) {
} else if (this._boundInput && this._boundInputInside) {
const copy = SelectorEngine.findOne(SELECTOR_BOUND_INPUT, clone)
if (copy) {
copy.replaceWith(this._boundInput)
@@ -324,8 +371,7 @@ class Datepicker extends BaseComponent {
}
setSelectedDates(dates: DatesArr): void {
this._selectedDates = dates.map((date) => this._toIsoDate(date))
this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) })
this._select(dates.map((date) => this._toIsoDate(date)))
this._writeSelection(this._selectedDates)
}
@@ -334,10 +380,8 @@ class Datepicker extends BaseComponent {
this._isInput = this._element.tagName === 'INPUT'
this._isInline = this._config.inline
// For inline mode, look for a hidden input child to bind to
if (this._isInline && !this._isInput) {
this._boundInput = SelectorEngine.findOne(SELECTOR_BOUND_INPUT, this._element) as HTMLInputElement | null
}
this._boundInput = this._resolveBoundInput()
this._boundInputInside = Boolean(this._boundInput && this._element.contains(this._boundInput))
this._positionElement = this._resolvePositionElement()
this._displayElement = this._resolveDisplayElement()
@@ -366,6 +410,13 @@ class Datepicker extends BaseComponent {
this._parseInputValue()
}
// The field shows localized text the next page load cannot read back, so
// a value the server rendered into the bound input selects the dates.
const boundDates = this._boundInput?.value.split(',').filter((date) => DATE_PATTERN.test(date))
if (this._selectedDates.length === 0 && boundDates?.length) {
this._select(boundDates)
}
// Populate input/display with preselected dates
this._updateDisplayWithSelectedDates()
}
@@ -395,6 +446,17 @@ class Datepicker extends BaseComponent {
}
}
_resolveBoundInput(): HTMLInputElement | null {
const { boundInput } = this._config
if (boundInput) {
return getElement(boundInput) as HTMLInputElement | null
}
// An inline calendar submits through a hidden input inside it
return this._isInline && !this._isInput ? (SelectorEngine.findOne(SELECTOR_BOUND_INPUT, this._element) as HTMLInputElement | null) : null
}
_resolvePositionElement(): HTMLElement {
let { positionElement } = this._config
@@ -679,6 +741,11 @@ class Datepicker extends BaseComponent {
const locale = this._config.locale === 'default' ? undefined : this._config.locale
const { dateFormat } = this._config
// Token pattern, independent of the locale
if (typeof dateFormat === 'string') {
return formatPattern(date, this._formatPattern()!)
}
// Custom function formatter
if (typeof dateFormat === 'function') {
return dateFormat(date, locale)
@@ -693,6 +760,20 @@ class Datepicker extends BaseComponent {
return date.toLocaleDateString(locale)
}
_formatPattern(): string | null {
const { dateFormat } = this._config
if (typeof dateFormat !== 'string') {
return null
}
return dateFormat.toLowerCase() === 'iso' ? ISO_FORMAT : dateFormat
}
_dateSeparator(): string {
return this._config.selectionMode === 'multiple-ranged' ? ' – ' : ', '
}
_formatDateForInput(dates: string[]): string {
if (dates.length === 0) {
return ''
@@ -703,8 +784,7 @@ class Datepicker extends BaseComponent {
}
// For date ranges, use en-dash; for multiple dates, use comma
const separator = this._config.selectionMode === 'multiple-ranged' ? ' – ' : ', '
return dates.map((date) => this._formatDate(date)).join(separator)
return dates.map((date) => this._formatDate(date)).join(this._dateSeparator())
}
_parseInputValue(): void {
@@ -715,11 +795,21 @@ class Datepicker extends BaseComponent {
const written = writtenSelections.get(this._element)
if (written && written.text.trim() === value) {
this._selectedDates = [...written.dates]
this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) })
this._select(written.dates)
return
}
// A pattern reads back what it wrote, e.g. a value the server sent again
const pattern = this._formatPattern()
if (pattern) {
const parts = this._config.selectionMode === 'single' ? [value] : value.split(this._dateSeparator())
const dates = parts.map((part) => parsePattern(part.trim(), pattern))
if (dates.every(Boolean)) {
this._select(dates as string[])
return
}
}
// `YYYY-MM-DD` is read as a local date: `new Date('2020-06-20')` would be
// UTC midnight and land on the previous day west of Greenwich.
const date = DATE_PATTERN.test(value) ? this._parseDate(value) : new Date(value)
@@ -727,8 +817,12 @@ class Datepicker extends BaseComponent {
return
}
this._selectedDates = [this._toIsoDate(date)]
// The calendar opens on the month of the value, not on today.
this._select([this._toIsoDate(date)])
}
_select(dates: string[]): void {
this._selectedDates = [...dates]
// The calendar opens on the month of the selection, not on today.
this._calendar?.set({ selectedDates: this._selectedDates, ...this._monthOf(this._selectedDates[0]) })
}
@@ -788,7 +882,7 @@ EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function
initAll(SELECTOR_DATA_TOGGLE, Datepicker, (element) => {
const { dataset } = element
return dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || Boolean((element as HTMLInputElement).value)
return dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || 'bsBoundInput' in dataset || 'tblrBoundInput' in dataset || Boolean((element as HTMLInputElement).value)
})
// js-docs-end datepicker-init
+95
View File
@@ -502,4 +502,99 @@ describe('Datepicker', () => {
expect(input().value).toBe('not a date')
})
})
describe('format pattern and bound input', () => {
it('writes an ISO value with the iso keyword whatever the locale', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-locale="pl-PL" data-bs-date-format="iso" value="2026-09-30">'
const instance = new Datepicker(input())
expect(instance._config.dateFormat).toBe('iso')
expect(input().value).toBe('2026-09-30')
expect(instance._config.locale).toBe('pl-PL')
})
it('writes a token pattern from a data attribute', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-date-format="D/M/YYYY">'
const instance = new Datepicker(input())
instance.setSelectedDates(['2026-03-04'])
expect(input().value).toBe('4/3/2026')
})
it('joins a range written with a pattern', () => {
fixtureEl.innerHTML = '<input type="text">'
const instance = new Datepicker(input(), { dateFormat: 'DD.MM.YYYY', selectionMode: 'multiple-ranged' })
instance.setSelectedDates(['2026-06-10', '2026-06-18'])
expect(input().value).toBe('10.06.2026 – 18.06.2026')
})
it('reads a value written with the pattern back', () => {
fixtureEl.innerHTML = '<input type="text" value="30.09.2026">'
const instance = new Datepicker(input(), { dateFormat: 'DD.MM.YYYY' })
expect(instance.getSelectedDates()).toEqual(['2026-09-30'])
expect(input().value).toBe('30.09.2026')
})
it('reads a range written with the pattern back', () => {
fixtureEl.innerHTML = '<input type="text" value="10.06.2026 – 18.06.2026">'
const instance = new Datepicker(input(), { dateFormat: 'DD.MM.YYYY', selectionMode: 'multiple-ranged' })
expect(instance.getSelectedDates()).toEqual(['2026-06-10', '2026-06-18'])
})
it('does not read a day that does not exist', () => {
fixtureEl.innerHTML = '<input type="text" value="31.02.2026">'
const instance = new Datepicker(input(), { dateFormat: 'DD.MM.YYYY' })
expect(instance.getSelectedDates()).toEqual([])
expect(input().value).toBe('31.02.2026')
})
it('still reads an ISO value with another pattern', () => {
fixtureEl.innerHTML = '<input type="text" value="2026-09-30">'
const instance = new Datepicker(input(), { dateFormat: 'DD.MM.YYYY' })
expect(instance.getSelectedDates()).toEqual(['2026-09-30'])
expect(input().value).toBe('30.09.2026')
})
it('writes ISO dates into a bound input next to a popup field', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-locale="pl-PL" data-bs-bound-input="#due"><input type="hidden" id="due" name="due">'
const hidden = fixtureEl.querySelector<HTMLInputElement>('#due')!
const instance = new Datepicker(input())
instance.setSelectedDates(['2026-09-30'])
expect(input().value).toBe('30.09.2026')
expect(hidden.value).toBe('2026-09-30')
})
it('selects the dates of a bound input the server filled in', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-locale="pl-PL" data-bs-bound-input="#due"><input type="hidden" id="due" name="due" value="2026-09-30">'
const instance = new Datepicker(input())
expect(instance.getSelectedDates()).toEqual(['2026-09-30'])
expect(input().value).toBe('30.09.2026')
})
it('leaves a bound input outside the element in place on dispose', () => {
fixtureEl.innerHTML = '<button type="button" data-bs-bound-input="#due">Pick</button><input type="hidden" id="due" name="due">'
const hidden = fixtureEl.querySelector<HTMLInputElement>('#due')!
new Datepicker(fixtureEl.querySelector('button')!).dispose()
expect(hidden.parentElement).toBe(fixtureEl)
expect(fixtureEl.querySelector('button')!.contains(hidden)).toBe(false)
})
})
})
+2 -2
View File
@@ -212,7 +212,7 @@
},
{
"path": "./dist/js/tabler.js",
"maxSize": "58 kB"
"maxSize": "59 kB"
},
{
"path": "./dist/js/tabler.min.js",
@@ -220,7 +220,7 @@
},
{
"path": "./dist/js/tabler.esm.js",
"maxSize": "58 kB"
"maxSize": "59 kB"
},
{
"path": "./dist/js/tabler.esm.min.js",
+35 -4
View File
@@ -149,6 +149,25 @@ The popup lines up with the left edge of the field. Set `data-bs-placement` to `
<input type="text" class="form-control" id="datepicker-right" data-bs-toggle="datepicker" autocomplete="off" data-bs-placement="right" placeholder="Select a date" />
</Example>
### Date format
The field shows the date in the format of `data-bs-locale`. Set `data-bs-date-format` to a pattern to write it the same way in every language. The pattern uses `YYYY` for the year, `MM` and `M` for the month, and `DD` and `D` for the day. The short tokens drop the leading zero. Everything else is copied as it is. Use `iso` as a shortcut for `YYYY-MM-DD`.
<Example>
<div class="row g-3">
<div class="col-sm-6">
<label class="form-label" for="datepicker-format-iso">ISO date, Polish calendar</label>
<input type="text" class="form-control" id="datepicker-format-iso" data-bs-toggle="datepicker" autocomplete="off" data-bs-locale="pl-PL" data-bs-date-format="iso" value="2026-09-30" />
</div>
<div class="col-sm-6">
<label class="form-label" for="datepicker-format-pattern">Custom pattern</label>
<input type="text" class="form-control" id="datepicker-format-pattern" data-bs-toggle="datepicker" autocomplete="off" data-bs-date-format="DD/MM/YYYY" placeholder="DD/MM/YYYY" />
</div>
</div>
</Example>
A field with a pattern also reads its `value` in that pattern, so a form the server sends back keeps the picked date selected.
### Button trigger
Any element can open the calendar, not only an input. A button shows the picked date as its text. Put the text in a child with `data-bs-datepicker-display` so an icon next to it stays.
@@ -197,11 +216,22 @@ To submit the picked dates with a form, put a hidden input inside the calendar.
</form>
</Example>
A popup field works the same way. Point `data-bs-bound-input` at a hidden input and give the `name` to that input, not to the field. The field shows the date in the visitor's language, and the form sends `YYYY-MM-DD`. When the hidden input already has a value, the picker selects that date on page load.
<Example>
<form onsubmit="return false">
<label class="form-label" for="datepicker-bound">Due date</label>
<input type="text" class="form-control" id="datepicker-bound" data-bs-toggle="datepicker" autocomplete="off" data-bs-locale="pl-PL" data-bs-bound-input="#datepicker-bound-value" placeholder="Select a date" />
<input type="hidden" id="datepicker-bound-value" name="due_date" value="2026-09-30" />
<button type="submit" class="btn btn-primary mt-3">Submit</button>
</form>
</Example>
## JavaScript
### Via data attributes
Add `data-bs-toggle="datepicker"` and Tabler does the rest. Inputs open on focus, other elements open on click, and inline calendars and fields with a value or `data-bs-selected-dates` render on page load. Every option below can be set as a `data-bs-*` attribute, for example `data-bs-selection-mode="multiple"`.
Add `data-bs-toggle="datepicker"` and Tabler does the rest. Inputs open on focus, other elements open on click, and inline calendars and fields with a value, `data-bs-selected-dates` or `data-bs-bound-input` render on page load. Every option below can be set as a `data-bs-*` attribute, for example `data-bs-selection-mode="multiple"`.
<CodeDocs name="datepicker-init" file="core/js/src/datepicker.ts" />
@@ -223,12 +253,13 @@ const datepicker = new tabler.Datepicker(document.getElementById('my-date'), {
| --- | --- | --- | --- |
| `dateMin` | string, number, Date | `null` | First pickable date, as `YYYY-MM-DD`. |
| `dateMax` | string, number, Date | `null` | Last pickable date, as `YYYY-MM-DD`. |
| `dateFormat` | object, function | `null` | How the date is shown in the field. Pass `Intl.DateTimeFormat` options or a `function(date, locale)` that returns a string. |
| `boundInput` | string, element | `null` | Input that gets the picked dates as `YYYY-MM-DD`, separated by commas. An inline calendar uses a hidden input inside it. |
| `dateFormat` | string, object, function | `null` | How the date is shown in the field. Pass a pattern such as `'DD.MM.YYYY'`, `'iso'`, `Intl.DateTimeFormat` options or a `function(date, locale)` that returns a string. |
| `displayElement` | string, element, boolean | `null` | Element that shows the picked date. A button uses itself or its `[data-bs-datepicker-display]` child. Set `false` to show nothing. |
| `displayMonthsCount` | number | `1` | Months shown side by side. |
| `firstWeekday` | number | `1` | First day of the week: 0 is Sunday, 1 is Monday. |
| `inline` | boolean | `false` | Render the calendar in place instead of a popup. |
| `locale` | string | `'default'` | Locale for month names and the date shown in the field, for example `'en-US'` or `'de-DE'`. |
| `locale` | string | `'default'` | Locale for month names and the date shown in the field, for example `'en-US'` or `'de-DE'`. A `dateFormat` pattern takes over the field. |
| `positionElement` | string, element | `null` | Element the popup lines up with. Defaults to the `.input-icon` or `.input-group` wrapper, or the field itself. |
| `selectedDates` | array | `[]` | Preselected dates as `YYYY-MM-DD`. |
| `selectionMode` | string | `'single'` | `'single'`, `'multiple'` or `'multiple-ranged'`. |
@@ -298,5 +329,5 @@ Tabler 1.5 and older used [Litepicker](https://github.com/wakirin/Litepicker), w
| `singleMode: false` | `data-bs-selection-mode="multiple-ranged"` |
| `minDate` / `maxDate` | `data-bs-date-min` / `data-bs-date-max` |
| `numberOfMonths: 2` | `data-bs-display-months-count="2"` |
| `format` | `dateFormat` option, as `Intl.DateTimeFormat` options |
| `format: 'DD.MM.YYYY'` | `data-bs-date-format="DD.MM.YYYY"` (tokens `YYYY`, `MM`, `M`, `DD`, `D`) |
| `picker.on('selected', …)` | `change.bs.datepicker` event |