diff --git a/.changeset/datepicker-format-pattern.md b/.changeset/datepicker-format-pattern.md new file mode 100644 index 000000000..e4d4d6f6f --- /dev/null +++ b/.changeset/datepicker-format-pattern.md @@ -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`. diff --git a/core/js/src/datepicker.ts b/core/js/src/datepicker.ts index 8e7ae8e0b..394a5f358 100644 --- a/core/js/src/datepicker.ts +++ b/core/js/src/datepicker.ts @@ -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 & Record 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 = { + 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 = { vcpOptions: 'object', } +const pad = (value: number, length = 2): string => String(value).padStart(length, '0') + +const formatPattern = (date: Date, pattern: string): string => { + const parts: Record = { + 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 = { 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 diff --git a/core/js/tests/unit/datepicker.spec.ts b/core/js/tests/unit/datepicker.spec.ts index 4cb64ccb1..74526d5c6 100644 --- a/core/js/tests/unit/datepicker.spec.ts +++ b/core/js/tests/unit/datepicker.spec.ts @@ -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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + + 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 = '' + const hidden = fixtureEl.querySelector('#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 = '' + + 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 = '' + const hidden = fixtureEl.querySelector('#due')! + + new Datepicker(fixtureEl.querySelector('button')!).dispose() + + expect(hidden.parentElement).toBe(fixtureEl) + expect(fixtureEl.querySelector('button')!.contains(hidden)).toBe(false) + }) + }) }) diff --git a/core/package.json b/core/package.json index 8a181cc92..b1b7e7260 100644 --- a/core/package.json +++ b/core/package.json @@ -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", diff --git a/docs/content/ui/plugins/date-picker.mdx b/docs/content/ui/plugins/date-picker.mdx index b17a14933..4bca897de 100644 --- a/docs/content/ui/plugins/date-picker.mdx +++ b/docs/content/ui/plugins/date-picker.mdx @@ -149,6 +149,25 @@ The popup lines up with the left edge of the field. Set `data-bs-placement` to ` +### 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`. + + +
+
+ + +
+
+ + +
+
+
+ +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. +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. + + +
+ + + + +
+
+ ## 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"`. @@ -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 |