Add Datepicker plugin on Vanilla Calendar Pro and drop Litepicker (#3062)

This commit is contained in:
Paweł Kuna authored and GitHub committed 2026-09-18 16:24:26 +02:00
1 parent 8636174130
commit 118380da73
28 files changed
+1921 -387

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ The module graph uses `@use` / `@forward`: a partial starts with `@use '../confi
Write `--badge-bg`, not `--tblr-badge-bg`. The public `--tblr-` prefix is added at build time by `.build/css-var-prefix.ts` (a postcss pass in `build-css.ts`).
The consequence to remember: **names owned by third-party libraries must not be prefixed.** `cssVarIgnore` lists them (`--bs-`, `--fc-`, `--gl-`, `--litepicker-`, `--plyr-`, `--ts-`, …). Prefixing one detaches the theming with no error anywhere — the library keeps reading its own name and simply never sees the value. When a vendor override introduces a new foreign name, add it to `cssVarIgnore`; `core/scss/tests/css-var-prefix.test.mjs` snapshots every custom property of `tabler-vendors.scss`, so a missing entry shows up as a `--tblr-`-prefixed foreign name in the snapshot diff.
The consequence to remember: **names owned by third-party libraries must not be prefixed.** `cssVarIgnore` lists them (`--bs-`, `--fc-`, `--gl-`, `--plyr-`, `--ts-`, …). Prefixing one detaches the theming with no error anywhere — the library keeps reading its own name and simply never sees the value. When a vendor override introduces a new foreign name, add it to `cssVarIgnore`; `core/scss/tests/css-var-prefix.test.mjs` snapshots every custom property of `tabler-vendors.scss`, so a missing entry shows up as a `--tblr-`-prefixed foreign name in the snapshot diff.
The snapshot moves in the other direction too. A vendor override that starts reading a Tabler name (`var(--success)`, `var(--form-valid-border-color)`) adds a correctly prefixed `--tblr-*` entry, and the test fails until the snapshot is refreshed. Read the diff: every new name is one Tabler owns → `pnpm --dir core exec vitest run --config vitest.scss.config.mjs -u` and commit the `.snap`; a name a library owns → `cssVarIgnore`, never `-u`.
-1
View File
@@ -21,7 +21,6 @@ export const cssVarIgnore = [
/^--bs-/, // bootstrap
/^--fc-/, // fullcalendar
/^--gl-/, // star-rating.js
/^--litepicker-/, // litepicker
/^--plyr-/, // plyr
/^--ts-/, // tom-select
'--section-bg', // marketing sections, unprefixed since it was introduced
+7
View File
@@ -0,0 +1,7 @@
---
"@tabler/core": minor
"@tabler/preview": minor
"@tabler/docs": patch
---
Added `Datepicker` plugin built on Vanilla Calendar Pro with a `datepicker` preview page, and removed Litepicker from `dist/libs`.
+1 -1
View File
@@ -107,7 +107,7 @@ The `@tabler/core` package contains compiled and minified CSS and JavaScript, th
│ ├── css/ tabler.css and the optional stylesheets (flags, marketing, payments, socials,
│ │ themes, vendors), each with .min and .rtl versions
│ ├── js/ tabler.js and tabler.esm.js, plus the standalone tabler-theme.js
│ ├── libs/ bundled plugins: ApexCharts, Tom Select, Litepicker, FullCalendar and others
│ ├── libs/ bundled plugins: ApexCharts, Tom Select, Vanilla Calendar Pro, FullCalendar and others
│ ├── types/ TypeScript declarations
│ ├── fonts/
│ └── img/
+1 -1
View File
@@ -87,7 +87,7 @@ The sources write custom properties without a prefix. Add [postcss-prefix-custom
### Plugins
The third-party libraries used by the demo pages (ApexCharts, Tom Select, Litepicker, FullCalendar, Dropzone and others) are bundled in `dist/libs`. The list of shipped files is in [libs.json](https://github.com/tabler/tabler/blob/dev/core/libs.json), and each plugin has its own page in the [plugins documentation](https://docs.tabler.io/ui/plugins/).
The third-party libraries used by the demo pages (ApexCharts, Tom Select, Vanilla Calendar Pro, FullCalendar, Dropzone and others) are bundled in `dist/libs`. The list of shipped files is in [libs.json](https://github.com/tabler/tabler/blob/dev/core/libs.json), and each plugin has its own page in the [plugins documentation](https://docs.tabler.io/ui/plugins/).
## What's included
+598
View File
@@ -0,0 +1,598 @@
/**
* --------------------------------------------------------------------------
* Tabler datepicker.ts
* Licensed under MIT (https://github.com/tabler/tabler/blob/dev/LICENSE)
* --------------------------------------------------------------------------
*/
import type { Calendar, DateAny, DateMode, DatesArr, MonthsCount, Options, PositionToInput, Range, WeekDayID } from 'vanilla-calendar-pro'
import BaseComponent from './bootstrap/base-component'
import EventHandler from './bootstrap/dom/event-handler'
import SelectorEngine from './bootstrap/dom/selector-engine'
import { isDisabled } from './bootstrap/util/index'
import type { ElementSelector } from './bootstrap/types'
/**
* Constants
*/
const NAME = 'datepicker'
const DATA_KEY = `bs.${NAME}`
const EVENT_KEY = `.${DATA_KEY}`
const DATA_API_KEY = '.data-api'
const EVENT_CHANGE = `change${EVENT_KEY}`
const EVENT_SHOW = `show${EVENT_KEY}`
const EVENT_SHOWN = `shown${EVENT_KEY}`
const EVENT_HIDE = `hide${EVENT_KEY}`
const EVENT_HIDDEN = `hidden${EVENT_KEY}`
const EVENT_FOCUSIN = `focusin${EVENT_KEY}`
const EVENT_CLICK_DATA_API = `click${EVENT_KEY}${DATA_API_KEY}`
const EVENT_FOCUSIN_DATA_API = `focusin${EVENT_KEY}${DATA_API_KEY}`
const SELECTOR_DATA_TOGGLE = '[data-bs-toggle="datepicker"], [data-tblr-toggle="datepicker"]'
const SELECTOR_DISPLAY = '[data-bs-datepicker-display], [data-tblr-datepicker-display]'
const SELECTOR_INPUT_WRAPPER = '.input-icon, .input-group'
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})$/
// The date, weekday, month-count and placement options are handed straight to
// Vanilla Calendar Pro, so they use its own literal unions rather than the wider
// `string` / `number`. That keeps a bad value a compile error here instead of a
// silent no-op inside the calendar.
type ComponentConfig = {
/** 'light', 'dark' or 'auto' for the popup only; null inherits from the nearest `[data-bs-theme]` */
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
/** element that shows the formatted date; a button uses itself or its `[data-bs-datepicker-display]` child */
displayElement: string | HTMLElement | boolean | null
displayMonthsCount: MonthsCount
/** 0 = Sunday, 1 = Monday */
firstWeekday: WeekDayID
/** render the calendar in place instead of a popup */
inline: boolean
locale: string
/** element the popup is aligned with; defaults to the `.input-icon` / `.input-group` wrapper or the element */
positionElement: string | HTMLElement | null
/** preselected dates as `YYYY-MM-DD` */
selectedDates: string[]
selectionMode: DateMode
placement: PositionToInput
/** pass-through for any Vanilla Calendar Pro option */
vcpOptions: Options
}
type ComponentConfigInput = Partial<ComponentConfig> & Record<string, unknown>
type ChangeEventArgs = { dates: string[]; event: MouseEvent }
const Default: ComponentConfig = {
datepickerTheme: null,
dateMin: null,
dateMax: null,
dateFormat: null,
displayElement: null,
displayMonthsCount: 1,
firstWeekday: 1,
inline: false,
locale: 'default',
positionElement: null,
selectedDates: [],
selectionMode: 'single',
placement: 'left',
vcpOptions: {},
}
const DefaultType: Record<keyof ComponentConfig, string> = {
datepickerTheme: '(null|string)',
dateMin: '(null|string|number|object)',
dateMax: '(null|string|number|object)',
dateFormat: '(null|object|function)',
displayElement: '(null|string|element|boolean)',
displayMonthsCount: 'number',
firstWeekday: 'number',
inline: 'boolean',
locale: 'string',
positionElement: '(null|string|element)',
selectedDates: 'array',
selectionMode: 'string',
placement: 'string',
vcpOptions: 'object',
}
/**
* Class definition
*
* Wraps Vanilla Calendar Pro (https://vanilla-calendar.pro), loaded separately
* as `window.VanillaCalendarPro`. Without the plugin the component is inert.
* Same options, methods and events as Bootstrap 6's Datepicker, so markup
* written for one works with the other.
*/
class Datepicker extends BaseComponent {
declare _element: HTMLElement & { value: string }
declare _config: ComponentConfig
_calendar: Calendar | null = null
_isShown = false
_isInput = false
_isInline = false
_boundInput: HTMLInputElement | null = null
_positionElement: HTMLElement | null = null
_displayElement: HTMLElement | false | null = null
_themeObserver: MutationObserver | null = null
_onFocusIn: ((event: Event) => void) | null = null
// The plugin builds a popup lazily, so its context has no selection until
// the first show; this mirror answers `getSelectedDates()` before that.
_selectedDates: string[] = []
constructor(element: ElementSelector, config?: ComponentConfigInput) {
super(element, config)
if (!this._element || !window.VanillaCalendarPro) {
return
}
this._initCalendar()
}
// Getters
static get Default(): ComponentConfig {
return Default
}
static get DefaultType(): Record<keyof ComponentConfig, string> {
return DefaultType
}
static get NAME(): string {
return NAME
}
/** The Vanilla Calendar Pro instance, for options the component does not expose. */
get calendar(): Calendar | null {
return this._calendar
}
// Public
toggle(): Promise<void> {
if (this._config.inline) {
return Promise.resolve() // Inline calendars are always visible
}
return this._isShown ? this.hide() : this.show()
}
async show(): Promise<void> {
if (this._config.inline) {
return // Inline calendars are always visible
}
if (!this._calendar || isDisabled(this._element) || this._isShown) {
return
}
const showEvent = EventHandler.trigger(this._element, EVENT_SHOW)
if (showEvent?.defaultPrevented) {
return
}
this._calendar.show()
this._isShown = true
EventHandler.trigger(this._element, EVENT_SHOWN)
}
async hide(): Promise<void> {
if (this._config.inline) {
return // Inline calendars are always visible
}
if (!this._calendar || !this._isShown) {
return
}
const hideEvent = EventHandler.trigger(this._element, EVENT_HIDE)
if (hideEvent?.defaultPrevented) {
return
}
this._calendar.hide()
this._isShown = false
EventHandler.trigger(this._element, EVENT_HIDDEN)
}
dispose(): void {
this._themeObserver?.disconnect()
if (this._onFocusIn) {
EventHandler.off(document, EVENT_FOCUSIN, this._onFocusIn)
}
this._calendar?.destroy()
super.dispose()
}
getSelectedDates(): string[] {
const dates = this._calendar?.context?.selectedDates
return dates && dates.length > 0 ? [...dates] : [...this._selectedDates]
}
setSelectedDates(dates: DatesArr): void {
this._selectedDates = dates.map(String)
this._calendar?.set({ selectedDates: dates })
}
// Private
_initCalendar(): void {
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._positionElement = this._resolvePositionElement()
this._displayElement = this._resolveDisplayElement()
this._selectedDates = [...this._config.selectedDates]
// The calendar always attaches to the element itself. The plugin closes on
// any click whose target is not exactly its input, so handing it a wrapper
// makes a click on the field inside close and reopen the popup. The popup
// is aligned with the position element after each show instead.
this._calendar = new window.VanillaCalendarPro!.Calendar(this._element, this._buildCalendarOptions())
this._calendar.init()
// An inline calendar renders into the element and drops its children, so
// the bound input moves right after it to stay in the form.
if (this._boundInput && !this._boundInput.isConnected) {
this._element.after(this._boundInput)
}
// Watch for theme changes on ancestor elements (for live theme switching)
this._setupThemeObserver()
this._setupDismissOnFocus()
// Set initial value if input has a value
if (this._isInput && this._element.value) {
this._parseInputValue()
}
// Populate input/display with preselected dates
this._updateDisplayWithSelectedDates()
}
_updateDisplayWithSelectedDates(): void {
const { selectedDates } = this._config
if (!selectedDates || selectedDates.length === 0) {
return
}
this._writeSelection(selectedDates)
}
_writeSelection(selectedDates: string[]): void {
const formattedDate = this._formatDateForInput(selectedDates)
if (this._isInput) {
this._element.value = formattedDate
}
if (this._boundInput) {
this._boundInput.value = selectedDates.join(',')
}
if (this._displayElement) {
this._displayElement.textContent = formattedDate
}
}
_resolvePositionElement(): HTMLElement {
let { positionElement } = this._config
if (typeof positionElement === 'string') {
positionElement = SelectorEngine.findOne(positionElement)
}
// Use the input's wrapper when it sits in an icon or group wrapper
if (!positionElement && this._isInput && !this._isInline) {
positionElement = this._element.closest<HTMLElement>(SELECTOR_INPUT_WRAPPER)
}
return positionElement || this._element
}
_resolveDisplayElement(): HTMLElement | false | null {
const { displayElement } = this._config
if (typeof displayElement === 'string') {
return SelectorEngine.findOne(displayElement)
}
// For buttons/non-inputs (not inline), look for a display child
if (displayElement === true || (displayElement === null && !this._isInput && !this._isInline)) {
return SelectorEngine.findOne(SELECTOR_DISPLAY, this._element) || this._element
}
return displayElement
}
// The plugin positions the popup against the element; a wrapper such as
// `.input-icon` or `.input-group` wants the popup aligned with its own edges.
_alignToPositionElement(): void {
const mainElement = this._calendar?.context?.mainElement
if (!mainElement || this._isInline || !this._positionElement || this._positionElement === this._element) {
return
}
const anchor = this._positionElement.getBoundingClientRect()
const popup = mainElement.getBoundingClientRect()
let { placement } = this._config
if (placement === 'auto') {
placement = anchor.left + popup.width > document.documentElement.clientWidth ? 'right' : 'left'
}
const left = placement === 'right' ? anchor.right - popup.width : placement === 'center' ? anchor.left + (anchor.width - popup.width) / 2 : anchor.left
mainElement.style.left = `${left + window.scrollX}px`
}
_getThemeAncestor(): Element | null {
return this._element.closest('[data-bs-theme]')
}
_getEffectiveTheme(): string | null {
// Priority: explicit datepickerTheme config > inherited from ancestor > none
const { datepickerTheme } = this._config
if (datepickerTheme) {
return datepickerTheme
}
return this._getThemeAncestor()?.getAttribute('data-bs-theme') || null
}
_syncThemeAttribute(element: HTMLElement | undefined): void {
if (!element) {
return
}
const theme = this._getEffectiveTheme()
if (theme) {
// Copy the theme to the popup: the plugin appends it to <body>, which
// breaks inheritance from a themed container.
element.setAttribute('data-bs-theme', theme)
} else {
element.removeAttribute('data-bs-theme')
}
}
_setupThemeObserver(): void {
const ancestor = this._getThemeAncestor()
if (!ancestor || this._config.datepickerTheme) {
// No ancestor to watch, or explicit datepickerTheme overrides
return
}
this._themeObserver = new MutationObserver(() => {
this._syncThemeAttribute(this._calendar?.context?.mainElement)
})
this._themeObserver.observe(ancestor, {
attributes: true,
attributeFilter: ['data-bs-theme'],
})
}
// The plugin ignores focus and mounts the popup on <body>, so close it
// ourselves when focus leaves both the element and the calendar.
_setupDismissOnFocus(): void {
if (this._isInline) {
return
}
this._onFocusIn = (event: Event): void => {
if (!this._isShown) {
return
}
const { target } = event
const mainElement = this._calendar?.context?.mainElement
if (target instanceof Node && (this._element.contains(target) || mainElement?.contains(target))) {
return
}
this.hide()
}
EventHandler.on(document, EVENT_FOCUSIN, this._onFocusIn)
}
_buildCalendarOptions(): Options {
// The plugin uses 'system' for auto-detection, Bootstrap and Tabler use 'auto'
const theme = this._getEffectiveTheme()
const vcpTheme = !theme || theme === 'auto' ? 'system' : theme
const calendarOptions: Options = {
...this._config.vcpOptions,
inputMode: !this._isInline,
positionToInput: this._config.placement,
firstWeekday: this._config.firstWeekday,
locale: this._config.locale,
selectionDatesMode: this._config.selectionMode,
selectedDates: this._config.selectedDates,
displayMonthsCount: this._config.displayMonthsCount,
type: this._config.displayMonthsCount > 1 ? 'multiple' : 'default',
selectedTheme: vcpTheme,
themeAttrDetect: '[data-bs-theme]',
onClickDate: (self, event) => this._handleDateClick(self, event),
onInit: (self) => {
this._syncThemeAttribute(self.context.mainElement)
},
onShow: () => {
if (!this._calendar) {
return
}
this._isShown = true
this._syncThemeAttribute(this._calendar.context.mainElement)
this._alignToPositionElement()
},
onHide: () => {
this._isShown = false
},
}
// Navigate to the month of the first selected date
const [firstSelected] = this._config.selectedDates
if (firstSelected) {
const firstDate = this._parseDate(firstSelected)
// `getMonth()` is always 0-11, which TypeScript cannot narrow on its own
calendarOptions.selectedMonth = firstDate.getMonth() as Range<12>
calendarOptions.selectedYear = firstDate.getFullYear()
}
if (this._config.dateMin) {
calendarOptions.dateMin = this._config.dateMin
}
if (this._config.dateMax) {
calendarOptions.dateMax = this._config.dateMax
}
return calendarOptions
}
_handleDateClick(self: Calendar, event: MouseEvent): void {
const selectedDates = [...self.context.selectedDates]
this._selectedDates = selectedDates
if (selectedDates.length > 0) {
this._writeSelection(selectedDates)
}
const args: ChangeEventArgs = { dates: selectedDates, event }
EventHandler.trigger(this._element, EVENT_CHANGE, args)
this._maybeHideAfterSelection(selectedDates)
}
_maybeHideAfterSelection(selectedDates: string[]): void {
if (this._isInline) {
return
}
const shouldHide = (this._config.selectionMode === 'single' && selectedDates.length > 0) || (this._config.selectionMode === 'multiple-ranged' && selectedDates.length >= 2)
if (shouldHide) {
setTimeout(() => this.hide(), HIDE_DELAY)
}
}
_parseDate(dateStr: string): Date {
const [year = 0, month = 1, day = 1] = dateStr.split('-').map(Number)
return new Date(year, month - 1, day)
}
_formatDate(dateStr: string): string {
const date = this._parseDate(dateStr)
const locale = this._config.locale === 'default' ? undefined : this._config.locale
const { dateFormat } = this._config
// Custom function formatter
if (typeof dateFormat === 'function') {
return dateFormat(date, locale)
}
// Intl.DateTimeFormat options object
if (dateFormat && typeof dateFormat === 'object') {
return new Intl.DateTimeFormat(locale, dateFormat).format(date)
}
// Default: locale-aware formatting
return date.toLocaleDateString(locale)
}
_formatDateForInput(dates: string[]): string {
if (dates.length === 0) {
return ''
}
if (dates.length === 1) {
return this._formatDate(dates[0]!)
}
// 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)
}
_parseInputValue(): void {
const value = this._element.value.trim()
if (!value) {
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)
if (Number.isNaN(date.getTime())) {
return
}
const year = date.getFullYear()
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
this._selectedDates = [`${year}-${month}-${day}`]
this._calendar?.set({ selectedDates: this._selectedDates })
}
}
/**
* Data API implementation
*/
// js-docs-start datepicker-init
EventHandler.on(document, EVENT_CLICK_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement, event: Event) {
// Inputs open on focus, and inline calendars are always visible
if (this.tagName === 'INPUT' || this.dataset.bsInline === 'true' || this.dataset.tblrInline === 'true') {
return
}
event.preventDefault()
;(Datepicker.getOrCreateInstance(this) as Datepicker).toggle()
})
EventHandler.on(document, EVENT_FOCUSIN_DATA_API, SELECTOR_DATA_TOGGLE, function (this: HTMLElement) {
if (this.tagName !== 'INPUT') {
return
}
;(Datepicker.getOrCreateInstance(this) as Datepicker).show()
})
// Render on load what cannot wait for a focus or a click: inline calendars,
// and fields with preselected dates or a value, so they show it right away.
for (const element of SelectorEngine.find(SELECTOR_DATA_TOGGLE)) {
const { dataset } = element
const eager = dataset.bsInline === 'true' || dataset.tblrInline === 'true' || 'bsSelectedDates' in dataset || 'tblrSelectedDates' in dataset || (element as HTMLInputElement).value
if (eager) {
Datepicker.getOrCreateInstance(element)
}
}
// js-docs-end datepicker-init
export default Datepicker
export type { ComponentConfig as DatepickerConfig, ChangeEventArgs as DatepickerChangeEventArgs }
+2
View File
@@ -16,6 +16,8 @@ interface SortableInstance {
}
interface Window {
// Vanilla Calendar Pro (https://vanilla-calendar.pro) is loaded separately, not bundled
VanillaCalendarPro?: typeof import('vanilla-calendar-pro')
IMask?: new (element: HTMLElement, options: { mask: string; lazy?: boolean }) => IMaskInstance
Sortable?: new (element: HTMLElement, options?: Record<string, unknown>) => SortableInstance
}
+2
View File
@@ -2,6 +2,7 @@ import './src/autosize'
import './src/clipboard'
import './src/confetti'
import './src/countup'
import './src/datepicker'
import './src/input-mask'
import './src/dropdown'
import './src/sidebar'
@@ -23,6 +24,7 @@ export { default as Autosize } from './src/autosize'
export { default as Clipboard } from './src/clipboard'
export { default as Confetti } from './src/confetti'
export { default as CountUp } from './src/countup'
export { default as Datepicker } from './src/datepicker'
export { default as InputMask } from './src/input-mask'
export { default as OtpInput } from './src/otp-input'
export { default as Sortable } from './src/sortable'
+247
View File
@@ -0,0 +1,247 @@
import { describe, it, expect, beforeAll, beforeEach, afterEach, vi } from 'vitest'
import * as VanillaCalendarPro from 'vanilla-calendar-pro'
import { clearFixture, getFixture } from '../helpers/fixture'
import Datepicker from '../../src/datepicker'
import EventHandler from '../../src/bootstrap/dom/event-handler'
describe('Datepicker', () => {
let fixtureEl: HTMLElement
beforeAll(() => {
fixtureEl = getFixture()
})
beforeEach(() => {
// The page loads the plugin as a global; the tests hand it over the same way.
window.VanillaCalendarPro = VanillaCalendarPro
})
afterEach(() => {
clearFixture()
delete window.VanillaCalendarPro
// The plugin mounts popups on <body>, outside the fixture
for (const calendarEl of document.querySelectorAll('[data-vc="calendar"]')) {
calendarEl.remove()
}
})
const input = (): HTMLInputElement => fixtureEl.querySelector('input')!
describe('NAME', () => {
it('should return plugin name', () => {
expect(Datepicker.NAME).toBe('datepicker')
})
})
describe('Default', () => {
it('should match the Bootstrap 6 defaults', () => {
expect(Datepicker.Default.dateMin).toBeNull()
expect(Datepicker.Default.dateMax).toBeNull()
expect(Datepicker.Default.selectionMode).toBe('single')
expect(Datepicker.Default.firstWeekday).toBe(1)
expect(Datepicker.Default.locale).toBe('default')
expect(Datepicker.Default.placement).toBe('left')
expect(Datepicker.Default.inline).toBe(false)
expect(Datepicker.Default.displayMonthsCount).toBe(1)
})
})
describe('constructor', () => {
it('should create the calendar on an input', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const instance = new Datepicker(input())
expect(instance.calendar).not.toBeNull()
expect(instance._isInput).toBe(true)
})
it('should stay inert without the plugin', () => {
delete window.VanillaCalendarPro
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const instance = new Datepicker(input())
expect(instance.calendar).toBeNull()
})
it('should detect a button trigger and use it as the display element', () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
const instance = new Datepicker(fixtureEl.querySelector('button')!)
expect(instance._isInput).toBe(false)
expect(instance._displayElement).toBe(fixtureEl.querySelector('button'))
})
it('should prefer a display child inside a button', () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="datepicker"><span data-bs-datepicker-display>Select date</span></button>'
const instance = new Datepicker(fixtureEl.querySelector('button')!)
expect(instance._displayElement).toBe(fixtureEl.querySelector('span'))
})
it('should read config from data attributes', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-first-weekday="0">'
const instance = new Datepicker(input())
expect(instance._config.selectionMode).toBe('multiple-ranged')
expect(instance._config.firstWeekday).toBe(0)
})
it('should let a passed config win over data attributes', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" data-bs-first-weekday="0">'
const instance = new Datepicker(input(), { selectionMode: 'multiple' })
expect(instance._config.selectionMode).toBe('multiple')
expect(instance._config.firstWeekday).toBe(0)
})
it('should write preselected dates into the input', () => {
fixtureEl.innerHTML = `<input type="text" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged" data-bs-selected-dates='["2026-06-10", "2026-06-18"]'>`
const instance = new Datepicker(input())
expect(instance._config.selectedDates).toEqual(['2026-06-10', '2026-06-18'])
expect(instance.getSelectedDates()).toEqual(['2026-06-10', '2026-06-18'])
expect(input().value).toContain(' – ')
})
it('should read a YYYY-MM-DD value as a local date', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" value="2020-06-20">'
const instance = new Datepicker(input())
expect(instance.getSelectedDates()).toEqual(['2020-06-20'])
})
it('should position the popup against the .input-icon wrapper', () => {
fixtureEl.innerHTML = '<div class="input-icon"><input type="text" data-bs-toggle="datepicker"><span class="input-icon-addon"></span></div>'
const instance = new Datepicker(input())
expect(instance._positionElement).toBe(fixtureEl.querySelector('.input-icon'))
})
it('should bind a hidden input inside an inline calendar', () => {
fixtureEl.innerHTML = '<div data-bs-toggle="datepicker" data-bs-inline="true"><input type="hidden" name="date"></div>'
const instance = new Datepicker(fixtureEl.querySelector('div')!, { selectedDates: ['2026-03-04'] })
expect(instance._isInline).toBe(true)
expect(input().isConnected).toBe(true)
expect(input().value).toBe('2026-03-04')
})
})
describe('show and hide', () => {
it('should fire show and shown, then hide and hidden', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const seen: string[] = []
for (const name of ['show', 'shown', 'hide', 'hidden']) {
input().addEventListener(`${name}.bs.datepicker`, () => seen.push(name))
}
const instance = new Datepicker(input())
await instance.show()
expect(instance._isShown).toBe(true)
await instance.hide()
expect(instance._isShown).toBe(false)
expect(seen).toEqual(['show', 'shown', 'hide', 'hidden'])
})
it('should stay closed when show is prevented', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
input().addEventListener('show.bs.datepicker', (event) => event.preventDefault())
const instance = new Datepicker(input())
await instance.show()
expect(instance._isShown).toBe(false)
})
it('should not show a disabled input', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker" disabled>'
const instance = new Datepicker(input())
const spy = vi.spyOn(EventHandler, 'trigger')
await instance.show()
expect(spy).not.toHaveBeenCalled()
spy.mockRestore()
})
it('should do nothing for an inline calendar', async () => {
fixtureEl.innerHTML = '<div data-bs-toggle="datepicker" data-bs-inline="true"></div>'
const instance = new Datepicker(fixtureEl.querySelector('div')!)
await instance.toggle()
expect(instance._isShown).toBe(false)
})
it('should hide when focus leaves the input and the calendar', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker"><button type="button">Next</button>'
const instance = new Datepicker(input())
await instance.show()
fixtureEl.querySelector('button')!.focus()
expect(instance._isShown).toBe(false)
})
})
describe('setSelectedDates', () => {
it('should update the calendar selection', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const instance = new Datepicker(input())
instance.setSelectedDates(['2026-01-15'])
expect(instance.getSelectedDates()).toEqual(['2026-01-15'])
})
})
describe('dispose', () => {
it('should destroy the calendar and drop the instance', () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
const instance = new Datepicker(input())
const destroy = vi.spyOn(instance.calendar!, 'destroy')
instance.dispose()
expect(destroy).toHaveBeenCalled()
expect(Datepicker.getInstance(input())).toBeNull()
})
})
describe('data-api', () => {
it('should open on focus of a toggle input', async () => {
fixtureEl.innerHTML = '<input type="text" data-bs-toggle="datepicker">'
input().focus()
await new Promise((resolve) => setTimeout(resolve, 0))
const instance = Datepicker.getInstance(input()) as Datepicker | null
expect(instance).not.toBeNull()
expect(instance!._isShown).toBe(true)
})
it('should toggle on click of a button trigger', async () => {
fixtureEl.innerHTML = '<button type="button" data-bs-toggle="datepicker">Select date</button>'
const button = fixtureEl.querySelector('button')!
button.click()
await new Promise((resolve) => setTimeout(resolve, 0))
const instance = Datepicker.getInstance(button) as Datepicker | null
expect(instance).not.toBeNull()
expect(instance!._isShown).toBe(true)
})
})
})
+32 -94
View File
@@ -1,92 +1,56 @@
{
"imask": {
"npm": "imask",
"js": [
"dist/imask.min.js"
]
"js": ["dist/imask.min.js"]
},
"apexcharts": {
"npm": "apexcharts",
"js": [
"dist/apexcharts.min.js"
]
"js": ["dist/apexcharts.min.js"]
},
"nouislider": {
"npm": "nouislider",
"js": [
"dist/nouislider.min.js"
],
"css": [
"dist/nouislider.min.css"
]
"js": ["dist/nouislider.min.js"],
"css": ["dist/nouislider.min.css"]
},
"lists": {
"npm": "list.js",
"js": [
"dist/list.min.js"
]
"js": ["dist/list.min.js"]
},
"sortablejs": {
"npm": "sortablejs",
"js": [
"Sortable.min.js"
]
"js": ["Sortable.min.js"]
},
"masonry": {
"js": [
"https://cdnjs.cloudflare.com/ajax/libs/masonry/4.2.2/masonry.pkgd.min.js"
]
"js": ["https://cdnjs.cloudflare.com/ajax/libs/masonry/4.2.2/masonry.pkgd.min.js"]
},
"mapbox": {
"js": [
"https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.js"
],
"css": [
"https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.css"
]
"js": ["https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.js"],
"css": ["https://api.mapbox.com/mapbox-gl-js/v1.8.0/mapbox-gl.css"]
},
"google-maps": {
"js": [
"https://maps.googleapis.com/maps/api/js?key=GOOGLE_MAPS_KEY"
]
"js": ["https://maps.googleapis.com/maps/api/js?key=GOOGLE_MAPS_KEY"]
},
"litepicker": {
"npm": "litepicker",
"js": [
"dist/litepicker.js"
]
"vanilla-calendar-pro": {
"npm": "vanilla-calendar-pro",
"js": ["index.js"]
},
"tom-select": {
"npm": "tom-select",
"js": [
"dist/js/tom-select.base.min.js"
],
"css": [
"dist/css/tom-select.bootstrap5.min.css"
]
"js": ["dist/js/tom-select.base.min.js"],
"css": ["dist/css/tom-select.bootstrap5.min.css"]
},
"jsvectormap": {
"npm": "jsvectormap",
"js": [
"dist/jsvectormap.min.js",
"dist/maps/world.js",
"dist/maps/world-merc.js"
],
"css": [
"dist/jsvectormap.css"
]
"js": ["dist/jsvectormap.min.js", "dist/maps/world.js", "dist/maps/world-merc.js"],
"css": ["dist/jsvectormap.css"]
},
"fslightbox": {
"npm": "fslightbox",
"js": [
"index.js"
]
"js": ["index.js"]
},
"hugerte": {
"npm": "hugerte",
"js": [
"hugerte.min.js"
],
"js": ["hugerte.min.js"],
"extra": [
"themes/silver/theme.min.js",
"models/dom/model.min.js",
@@ -102,65 +66,39 @@
},
"plyr": {
"npm": "plyr",
"js": [
"dist/plyr.min.js"
],
"css": [
"dist/plyr.css"
]
"js": ["dist/plyr.min.js"],
"css": ["dist/plyr.css"]
},
"dropzone": {
"npm": "dropzone",
"js": [
"dist/dropzone-min.js"
],
"css": [
"dist/dropzone.css"
]
"js": ["dist/dropzone-min.js"],
"css": ["dist/dropzone.css"]
},
"star-rating.js": {
"npm": "star-rating.js",
"js": [
"dist/star-rating.min.js"
],
"css": [
"dist/star-rating.min.css"
]
"js": ["dist/star-rating.min.js"],
"css": ["dist/star-rating.min.css"]
},
"coloris.js": {
"npm": "@melloware/coloris",
"js": [
"dist/umd/coloris.min.js"
],
"css": [
"dist/coloris.min.css"
]
"js": ["dist/umd/coloris.min.js"],
"css": ["dist/coloris.min.css"]
},
"typed.js": {
"npm": "typed.js",
"js": [
"dist/typed.umd.js"
]
"js": ["dist/typed.umd.js"]
},
"signature_pad": {
"npm": "signature_pad",
"js": [
"dist/signature_pad.umd.min.js"
]
"js": ["dist/signature_pad.umd.min.js"]
},
"fullcalendar": {
"npm": "fullcalendar",
"js": [
"index.global.min.js"
]
"js": ["index.global.min.js"]
},
"driver.js": {
"npm": "driver.js",
"js": [
"dist/driver.js.iife.js"
],
"css": [
"dist/driver.css"
]
"js": ["dist/driver.js.iife.js"],
"css": ["dist/driver.css"]
}
}
+5 -5
View File
@@ -82,19 +82,19 @@
"files": [
{
"path": "./dist/css/tabler.css",
"maxSize": "85 kB"
"maxSize": "86 kB"
},
{
"path": "./dist/css/tabler.min.css",
"maxSize": "79 kB"
"maxSize": "80 kB"
},
{
"path": "./dist/css/tabler.rtl.css",
"maxSize": "84 kB"
"maxSize": "86 kB"
},
{
"path": "./dist/css/tabler.rtl.min.css",
"maxSize": "79 kB"
"maxSize": "80 kB"
},
{
"path": "./dist/css/tabler-flags.css",
@@ -262,7 +262,6 @@
"imask": "^7.6.1",
"jsvectormap": "^1.7.0",
"list.js": "^2.3.1",
"litepicker": "^2.0.12",
"nouislider": "^15.8.1",
"playwright": "1.62.1",
"plyr": "^3.8.4",
@@ -274,6 +273,7 @@
"tom-select": "^2.6.2",
"typed.js": "^3.0.0",
"typescript": "^7.0.2",
"vanilla-calendar-pro": "^3.3.2",
"vitest": "4.1.11"
}
}
+1
View File
@@ -50,6 +50,7 @@
@forward 'ui/buttons';
@forward 'ui/button-group';
@forward 'ui/calendars';
@forward 'ui/datepicker';
@forward 'ui/carousel';
@forward 'ui/cards';
@forward 'ui/close';
+15
View File
@@ -2323,6 +2323,21 @@ $calendar-padding-y: 0.5rem !default;
$calendar-date-padding: 0.2rem !default;
$calendar-date-size: 1.4rem !default;
// Datepicker
// scss-docs-start datepicker-variables
$datepicker-calendar-padding: 0 !default;
$datepicker-calendar-font-size: $font-size-sm !default;
$datepicker-calendar-header-font-weight: $font-weight-medium !default;
$datepicker-calendar-day-size: 2rem !default;
$datepicker-calendar-day-font-size: $font-size-sm !default;
$datepicker-calendar-range-bg-opacity: 10% !default;
$datepicker-padding: 1rem !default;
$datepicker-bg: $dropdown-bg !default;
$datepicker-border-radius: $dropdown-border-radius !default;
$datepicker-box-shadow: $dropdown-box-shadow !default;
$datepicker-min-width: 280px !default;
// scss-docs-end datepicker-variables
// HR text
$hr-text-gap: 0.5rem !default;
$hr-text-spaceless-margin-y: -0.5rem !default;
-1
View File
@@ -1,7 +1,6 @@
@forward 'config';
@forward 'vendor/nouislider';
@forward 'vendor/litepicker';
@forward 'vendor/tom-select';
@forward 'vendor/apexcharts';
@forward 'vendor/jsvectormap';
@@ -21,24 +21,10 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
"--gl-star-color",
"--gl-star-color-inactive",
"--gl-star-size",
"--litepicker-button-next-month-color",
"--litepicker-button-next-month-color-hover",
"--litepicker-button-prev-month-color",
"--litepicker-button-prev-month-color-hover",
"--litepicker-container-months-color-bg",
"--litepicker-day-color",
"--litepicker-day-color-hover",
"--litepicker-is-end-color-bg",
"--litepicker-is-in-range-color",
"--litepicker-is-start-color-bg",
"--litepicker-is-today-color",
"--litepicker-month-header-color",
"--litepicker-month-weekday-color",
"--plyr-color-main",
"--tblr-active-bg",
"--tblr-azure",
"--tblr-backdrop-bg",
"--tblr-bg-forms",
"--tblr-bg-surface",
"--tblr-bg-surface-inverted",
"--tblr-bg-surface-secondary",
@@ -69,8 +55,6 @@ exports[`css custom-property prefixing > leaves vendor-owned names alone and pre
"--tblr-gray-500",
"--tblr-green",
"--tblr-icon-size",
"--tblr-info",
"--tblr-light",
"--tblr-orange",
"--tblr-primary",
"--tblr-primary-200",
+433
View File
@@ -0,0 +1,433 @@
// Datepicker
//
// Styles the `data-vc-*` markup that Vanilla Calendar Pro renders, the same way
// Bootstrap 6 does: the calendar grid is one set of tokens (`--calendar-*`),
// the popup surface the datepicker adds around it is another (`--datepicker-*`).
// An inline calendar renders unadorned and takes the surface of its container.
@use '../config' as *;
// stylelint-disable selector-max-attribute, selector-no-qualifying-type -- the plugin marks everything with data attributes
//
// Calendar grid
//
[data-vc='calendar'] {
--calendar-padding: #{$datepicker-calendar-padding};
--calendar-color: var(--body-color);
--calendar-font-size: #{$datepicker-calendar-font-size};
--calendar-header-font-weight: #{$datepicker-calendar-header-font-weight};
--calendar-weekday-color: var(--secondary);
--calendar-day-hover-bg: #{color-transparent(var(--body-color), 6%)};
--calendar-day-selected-bg: var(--primary);
--calendar-day-selected-color: var(--primary-fg);
--calendar-day-today-bg: #{color-transparent(var(--body-color), 6%)};
--calendar-day-today-color: var(--primary);
--calendar-day-disabled-color: var(--disabled-color);
--calendar-day-size: #{$datepicker-calendar-day-size};
--calendar-day-font-size: #{$datepicker-calendar-day-font-size};
--calendar-border-radius: var(--border-radius);
position: relative;
box-sizing: border-box;
display: flex;
flex-direction: column;
width: fit-content;
padding: var(--calendar-padding);
font-family: inherit;
font-size: var(--calendar-font-size);
color: var(--calendar-color);
color-scheme: light dark;
&[data-bs-theme='light'] {
color-scheme: light;
}
&[data-bs-theme='dark'] {
color-scheme: dark;
}
button {
font-family: inherit;
}
button:focus-visible {
position: relative;
z-index: 1;
@include focus-ring();
}
}
// Month navigation
[data-vc-arrow] {
position: relative;
display: block;
width: 2rem;
height: 2rem;
color: var(--calendar-color);
pointer-events: auto;
cursor: pointer;
background-color: transparent;
border: 0;
@include border-radius(var(--calendar-border-radius));
&::before {
position: absolute;
inset: 0.25rem;
content: '';
background-color: currentColor;
@include mask-icon(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'><path d='M12 16c-.3 0-.5-.1-.7-.3l-6-6c-.4-.4-.4-1 0-1.4s1-.4 1.4 0l5.3 5.3 5.3-5.3c.4-.4 1-.4 1.4 0s.4 1 0 1.4l-6 6c-.2.2-.4.3-.7.3'/></svg>"), $size: 1rem);
}
&:hover {
background-color: var(--calendar-day-hover-bg);
}
}
[data-vc-arrow='prev']::before {
transform: rotate(90deg);
}
[data-vc-arrow='next']::before {
transform: rotate(-90deg);
}
// The controls overlay the header, so they repeat the calendar's own inset to
// keep the arrows aligned with the content below.
[data-vc='controls'] {
position: absolute;
top: 0;
right: 0;
left: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: space-between;
padding-block-start: var(--calendar-padding);
padding-inline: var(--calendar-padding);
pointer-events: none;
}
[data-vc='grid'] {
display: flex;
flex-grow: 1;
flex-wrap: wrap;
gap: 1.75rem;
}
[data-vc='column'] {
display: flex;
flex-grow: 1;
flex-direction: column;
min-width: 240px;
}
// Header: month and year
[data-vc='header'] {
position: relative;
display: flex;
align-items: center;
margin-bottom: 0.75rem;
}
[data-vc-header='content'] {
display: inline-flex;
flex-grow: 1;
align-items: center;
justify-content: center;
white-space: pre-wrap;
}
[data-vc='month'],
[data-vc='year'] {
padding: 0.25rem 0.5rem;
margin-inline: -0.125rem;
font-size: var(--font-size-base, 1rem);
font-weight: var(--calendar-header-font-weight);
color: var(--calendar-color);
background-color: transparent;
border: 0;
@include border-radius(var(--calendar-border-radius));
&:disabled {
color: var(--calendar-day-disabled-color);
pointer-events: none;
}
&:hover:not(:disabled) {
background-color: var(--calendar-day-hover-bg);
}
}
[data-vc='content'] {
display: flex;
flex-grow: 1;
flex-direction: column;
}
// Month and year views. The plugin wraps each set in role="row" and
// role="gridcell" elements, so the column grid goes on the row.
[data-vc='months'],
[data-vc='years'] {
display: flex;
flex-grow: 1;
flex-direction: column;
row-gap: 1rem;
}
[data-vc-months='row'],
[data-vc-years='row'] {
display: grid;
grid-template-columns: repeat(var(--calendar-columns, 4), minmax(0, 1fr));
column-gap: 0.25rem;
align-items: center;
}
[data-vc='years'] {
--calendar-columns: 5;
}
[data-vc-months='cell'],
[data-vc-years='cell'] {
display: flex;
}
[data-vc-months-month],
[data-vc-years-year] {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 2.5rem;
padding: 0.25rem;
font-size: var(--calendar-day-font-size);
font-weight: var(--calendar-header-font-weight);
line-height: 1rem;
color: var(--calendar-weekday-color);
text-align: center;
word-break: break-all;
cursor: pointer;
background-color: transparent;
border: 0;
@include border-radius(var(--calendar-border-radius));
&:disabled {
color: var(--calendar-day-disabled-color);
pointer-events: none;
}
&:hover:not(:disabled) {
background-color: var(--calendar-day-hover-bg);
}
&[data-vc-months-month-selected],
&[data-vc-years-year-selected] {
color: var(--calendar-day-selected-color);
background-color: var(--calendar-day-selected-bg);
&:hover {
color: var(--calendar-day-selected-color);
background-color: var(--calendar-day-selected-bg);
}
}
}
// Weekday header
[data-vc='week'] {
display: grid;
grid-template-columns: repeat(7, 1fr);
justify-items: center;
margin-bottom: 0.5rem;
}
[data-vc-week-day] {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: var(--calendar-day-size);
padding: 0;
margin: 0;
font-size: var(--calendar-day-font-size);
font-weight: var(--calendar-header-font-weight);
line-height: 1rem;
color: var(--calendar-weekday-color);
background-color: transparent;
border: 0;
}
button[data-vc-week-day] {
cursor: pointer;
}
// Dates grid
[data-vc='dates'] {
pointer-events: none;
}
[data-vc-dates='row'] {
display: grid;
grid-template-columns: repeat(7, 1fr);
align-items: center;
justify-items: center;
width: 100%;
}
[data-vc-date] {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
padding-top: 0.125rem;
padding-bottom: 0.125rem;
pointer-events: auto;
&:not(:has([data-vc-date-btn])),
&[data-vc-date-disabled],
&[data-vc-date-disabled] [data-vc-date-btn] {
pointer-events: none;
}
}
// A day paints its background from two variables: the colour a state picks
// and the opacity it gets. States change the variables, never `opacity`, so
// the date stays readable on a faded background.
[data-vc-date-btn] {
--calendar-day-bg: transparent;
--calendar-day-bg-opacity: 100%;
display: flex;
align-items: center;
justify-content: center;
width: 100%;
min-width: var(--calendar-day-size);
height: 100%;
min-height: var(--calendar-day-size);
padding: 0;
font-size: var(--calendar-day-font-size);
font-weight: $font-weight-normal;
line-height: 1rem;
color: var(--calendar-color);
cursor: pointer;
background-color: color-transparent(var(--calendar-day-bg), var(--calendar-day-bg-opacity));
border: 0;
border-radius: var(--calendar-border-radius);
@include transition(color $transition-time, background-color $transition-time);
&:hover {
--calendar-day-bg: var(--calendar-day-hover-bg);
}
}
// Today
[data-vc-date-today] [data-vc-date-btn] {
font-weight: $font-weight-bold;
--calendar-day-bg: var(--calendar-day-today-bg);
color: var(--calendar-day-today-color);
}
// Days of the previous and next month
[data-vc-date-month='next'] [data-vc-date-btn],
[data-vc-date-month='prev'] [data-vc-date-btn] {
--calendar-day-bg-opacity: 50%;
color: var(--calendar-weekday-color);
}
[data-vc-date-disabled] [data-vc-date-btn] {
color: var(--calendar-day-disabled-color);
}
// Range selection: the hovered and selected runs join into one bar, so only the
// outer corners keep their radius. Logical corners follow the writing direction.
[data-vc-date-hover] [data-vc-date-btn] {
--calendar-day-bg: var(--calendar-day-hover-bg);
border-radius: 0;
}
[data-vc-date-hover='first'] [data-vc-date-btn] {
border-start-start-radius: var(--calendar-border-radius);
border-end-start-radius: var(--calendar-border-radius);
}
[data-vc-date-hover='last'] [data-vc-date-btn] {
border-start-end-radius: var(--calendar-border-radius);
border-end-end-radius: var(--calendar-border-radius);
}
[data-vc-date-hover='first-and-last'] [data-vc-date-btn] {
border-radius: var(--calendar-border-radius);
}
[data-vc-date-selected] [data-vc-date-btn] {
--calendar-day-bg: var(--calendar-day-selected-bg);
color: var(--calendar-day-selected-color);
}
// Inside a range only the two ends are solid; the days between keep their own
// text colour on a faint tint of the selected colour.
[data-vc-date-selected='middle'] [data-vc-date-btn] {
--calendar-day-bg-opacity: #{$datepicker-calendar-range-bg-opacity};
color: var(--calendar-color);
border-radius: 0;
}
[data-vc-date-selected='first'] [data-vc-date-btn] {
border-start-start-radius: var(--calendar-border-radius);
border-start-end-radius: 0;
border-end-start-radius: var(--calendar-border-radius);
border-end-end-radius: 0;
}
[data-vc-date-selected='last'] [data-vc-date-btn] {
border-start-start-radius: 0;
border-start-end-radius: var(--calendar-border-radius);
border-end-start-radius: 0;
border-end-end-radius: var(--calendar-border-radius);
}
[data-vc-date-selected='first-and-last'] [data-vc-date-btn] {
border-radius: var(--calendar-border-radius);
}
//
// Popup surface
//
// The plugin adds `[data-vc-input]` only when it owns a popup, so inline
// calendars keep the bare grid styling above.
[data-vc='calendar'][data-vc-input] {
--datepicker-padding: #{$datepicker-padding};
--datepicker-bg: #{$datepicker-bg};
--datepicker-border-radius: #{$datepicker-border-radius};
--datepicker-box-shadow: #{$datepicker-box-shadow};
--datepicker-min-width: #{$datepicker-min-width};
--datepicker-zindex: #{$zindex-dropdown};
--calendar-padding: var(--datepicker-padding);
position: absolute;
z-index: var(--datepicker-zindex);
min-width: var(--datepicker-min-width);
// Same surface as `.dropdown-menu`: the edge is the 1px ring inside the
// dropdown shadow, so the popup has no border of its own.
background-color: var(--datepicker-bg);
box-shadow: var(--datepicker-box-shadow);
@include border-radius(var(--datepicker-border-radius));
// The plugin creates the popup at 0/0 with this attribute, positions it on
// show, and puts the attribute back on hide. The selector repeats the popup
// one so it wins on specificity; otherwise the popup flashes in the corner
// and never disappears.
&[data-vc-calendar-hidden] {
pointer-events: none;
opacity: 0;
}
}
[data-vc-position='bottom'] {
margin-block-start: 0.25rem;
}
[data-vc-position='top'] {
margin-block-end: -0.25rem;
}
// stylelint-enable selector-max-attribute, selector-no-qualifying-type
-70
View File
@@ -1,70 +0,0 @@
@use '../config' as *;
.litepicker {
--litepicker-month-weekday-color: #{var(--secondary)};
--litepicker-button-prev-month-color: #{var(--secondary)};
--litepicker-button-next-month-color: #{var(--secondary)};
--litepicker-button-prev-month-color-hover: #{var(--primary)};
--litepicker-button-next-month-color-hover: #{var(--primary)};
--litepicker-day-color: var(--body-color);
--litepicker-day-color-hover: #{var(--primary)};
--litepicker-is-start-color-bg: #{var(--primary)};
--litepicker-is-end-color-bg: #{var(--primary)};
--litepicker-is-in-range-color: #{var(--info)};
--litepicker-is-today-color: #{var(--primary)};
--litepicker-month-header-color: var(--body-color);
--litepicker-container-months-color-bg: var(--bg-surface);
font: inherit;
user-select: none;
.day-item.is-in-range {
--litepicker-day-color: var(--light);
}
svg {
fill: none !important;
}
.container__main {
border: var(--border-width) var(--border-style) var(--border-color);
@include border-radius(var(--border-radius));
box-shadow: none;
}
.container__months {
.month-item-name,
.month-item-year {
font-weight: var(--font-weight-medium) !important;
}
.button-next-month,
.button-previous-month {
cursor: pointer !important;
}
.month-item-weekdays-row {
> div {
padding: 0.5rem 0 !important;
font-size: $h5-font-size;
}
}
}
.container__days {
.day-item {
padding: 0.5rem 0 !important;
cursor: pointer !important;
@include transition(color $transition-time, background-color $transition-time, border-color $transition-time);
}
}
.datepicker-inline & {
box-shadow: $input-box-shadow;
.container__months {
background-color: var(--bg-forms);
box-shadow: none;
@include border-radius(calc(var(--border-radius) - 1px));
}
}
}
+210 -86
View File
@@ -2,13 +2,13 @@
title: Date picker
order: 10
summary: A date picker lets users pick a date from a calendar instead of typing it, for booking forms, deadlines and other date fields.
docs-libs: [litepicker]
description: Let users pick a date from a calendar with the Litepicker plugin, as a plain input, an icon input, or an inline calendar.
related: [/ui/forms/elements, /ui/plugins/color-picker]
docs-libs: [vanilla-calendar-pro]
description: Let users pick a date, a range or several dates with the Datepicker plugin, as a text input, a button or an inline calendar.
related: [/ui/forms/elements, /ui/plugins/color-picker, /ui/plugins/fullcalendar]
---
import Example from '@components/Example.astro';
import CdnImportPlugin from '@components/CdnImportPlugin.astro';
import CodeDocs from '@components/CodeDocs.astro';
import TabsPackage from '@components/TabsPackage.astro';
import Icon from '@ui/Icon.astro';
import { Code } from 'astro:components';
@@ -16,142 +16,266 @@ import { site } from '@shared/lib/site.ts';
## Overview
The date picker is a normal text input with the [Litepicker](https://litepicker.com/) plugin attached. Litepicker opens a calendar when the field is focused and writes the picked date back into the input, so the field still works in a form like any other text field.
The date picker is Tabler's `Datepicker` plugin wrapped around [Vanilla Calendar Pro](https://vanilla-calendar.pro/). It is the same plugin, with the same options, methods and events, as the Datepicker in Bootstrap 6, so markup written for one works with the other. Tabler styles the calendar the plugin renders, so it follows your theme, your border radius and your dark mode.
Add `data-bs-toggle="datepicker"` to a text input. The calendar opens when the field gets focus, and the picked date is written back into the field, so it works in a form like any other text field.
<Example>
<input type="text" class="form-control" id="datepicker-overview" placeholder="Select a date" />
<label class="form-label" for="datepicker-overview">Date</label>
<input type="text" class="form-control" id="datepicker-overview" data-bs-toggle="datepicker" autocomplete="off" placeholder="Select a date" />
</Example>
## Installation
Install Litepicker with npm:
The plugin itself ships in `tabler.js`. The calendar it renders comes from Vanilla Calendar Pro, which you load separately. Install it with npm:
<TabsPackage name="litepicker" />
<TabsPackage name="vanilla-calendar-pro" />
Or include it from a CDN:
Or include it from a CDN, before `tabler.js`:
<Code lang="html" code={`<script src="${site.cdnUrl}/dist/libs/litepicker/dist/litepicker.js"></script>`} />
<Code lang="html" code={`<script src="${site.cdnUrl}/dist/libs/vanilla-calendar-pro/index.js"></script>`} />
Tabler restyles the calendar to match the rest of the interface. Those styles live in the vendors plugin, so include `tabler-vendors.css` as well:
<CdnImportPlugin plugins={['vendors']} />
You do not need the library's own stylesheet. Tabler styles the calendar in `tabler.css`.
## Usage
### Basic input
Add a text input with the `form-control` class, then attach Litepicker to it with its `element` option.
Use `type="text"`, not `type="date"`, so the browser's own picker does not open too. Add `autocomplete="off"` so the browser does not cover the calendar with old values.
<Example codeOnly>
<input type="text" class="form-control" id="datepicker-basic" placeholder="Select a date" />
<Example>
<label class="form-label" for="datepicker-basic">Date</label>
<input type="text" class="form-control" id="datepicker-basic" data-bs-toggle="datepicker" autocomplete="off" placeholder="Select a date" />
</Example>
```js
const picker = new Litepicker({
element: document.getElementById('datepicker-basic'),
});
```
### Icon input
Wrap the field in `.input-icon` and add a calendar icon, so users can see what the field is for at a glance. Use `.input-icon-addon` after the input to place the icon on the right, or before it to place the icon on the left.
Wrap the field in `.input-icon` and add a calendar icon, so users can see what the field is for at a glance. The popup lines up with the wrapper, not with the input inside it.
<Example>
<label class="form-label" for="datepicker-icon">Date</label>
<div class="input-icon">
<input type="text" class="form-control" id="datepicker-icon" placeholder="Select a date" />
<input type="text" class="form-control" id="datepicker-icon" data-bs-toggle="datepicker" autocomplete="off" placeholder="Select a date" />
<span class="input-icon-addon"><Icon name="calendar" /></span>
</div>
</Example>
### Initial value
A `value` in `YYYY-MM-DD` format is shown on load and the calendar opens on that month. You can also preselect dates with `data-bs-selected-dates`, which takes a JSON array.
<Example>
<div class="input-icon">
<span class="input-icon-addon"><Icon name="calendar" /></span>
<input type="text" class="form-control" id="datepicker-icon-prepend" placeholder="Select a date" />
<label class="form-label" for="datepicker-value">Birth date</label>
<input type="text" class="form-control" id="datepicker-value" data-bs-toggle="datepicker" autocomplete="off" value="1990-06-20" />
</Example>
### Min and max dates
Limit the pickable dates with `data-bs-date-min` and `data-bs-date-max`. Dates outside the range are shown but cannot be picked.
<Example>
<label class="form-label" for="datepicker-min-max">Event date (2026 only)</label>
<input type="text" class="form-control" id="datepicker-min-max" data-bs-toggle="datepicker" autocomplete="off" data-bs-date-min="2026-01-01" data-bs-date-max="2026-12-31" placeholder="Select a date in 2026" />
</Example>
### Date range
Set `data-bs-selection-mode="multiple-ranged"` to pick a start and an end date. The calendar closes after the second date. The field shows both dates with a dash between them.
<Example>
<label class="form-label" for="datepicker-range">Stay</label>
<input type="text" class="form-control" id="datepicker-range" data-bs-toggle="datepicker" autocomplete="off" data-bs-selection-mode="multiple-ranged" data-bs-selected-dates='["2026-06-10", "2026-06-18"]' placeholder="Select start and end" />
</Example>
### Multiple dates
Set `data-bs-selection-mode="multiple"` to pick any number of dates. The calendar stays open until focus leaves it. The field lists the dates with commas.
<Example>
<label class="form-label" for="datepicker-multiple">Days off</label>
<input type="text" class="form-control" id="datepicker-multiple" data-bs-toggle="datepicker" autocomplete="off" data-bs-selection-mode="multiple" placeholder="Select dates" />
</Example>
### Two months
Add `data-bs-display-months-count="2"` to show two months side by side. This helps with ranges that cross a month boundary.
<Example>
<label class="form-label" for="datepicker-two-months">Stay</label>
<input type="text" class="form-control" id="datepicker-two-months" data-bs-toggle="datepicker" autocomplete="off" data-bs-selection-mode="multiple-ranged" data-bs-display-months-count="2" placeholder="Select start and end" />
</Example>
### First day of the week
The week starts on Monday. Set `data-bs-first-weekday="0"` to start it on Sunday.
<Example>
<label class="form-label" for="datepicker-sunday">Date</label>
<input type="text" class="form-control" id="datepicker-sunday" data-bs-toggle="datepicker" autocomplete="off" data-bs-first-weekday="0" placeholder="Select a date" />
</Example>
### Placement
The popup lines up with the left edge of the field. Set `data-bs-placement` to `center`, `right` or `auto` to change that.
<Example>
<label class="form-label" for="datepicker-right">Right aligned</label>
<input type="text" class="form-control" id="datepicker-right" data-bs-toggle="datepicker" autocomplete="off" data-bs-placement="right" placeholder="Select a date" />
</Example>
### 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.
<Example>
<div class="btn-list">
<button type="button" class="btn" data-bs-toggle="datepicker">
<Icon name="calendar" />
<span data-bs-datepicker-display>Select date</span>
</button>
<button type="button" class="btn" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">
<Icon name="calendar-event" />
<span data-bs-datepicker-display>Select range</span>
</button>
</div>
</Example>
To show the date somewhere else, pass the `displayElement` option when you create the picker in JavaScript.
### Inline calendar
Give Litepicker an empty `div` instead of an input, and pass `inlineMode: true`. The calendar then renders directly on the page instead of opening in a popover. Use this when the date is the main thing on the screen, for example a booking or availability page.
Add `data-bs-inline="true"` to render the calendar in place instead of a popup. Tabler creates inline calendars on page load, so they need no extra JavaScript. An inline calendar has no background or border of its own. Put it in a card to give it a surface.
<Example>
<div class="datepicker-inline" id="datepicker-inline"></div>
<div class="card" style="width: fit-content">
<div class="card-body">
<div data-bs-toggle="datepicker" data-bs-inline="true"></div>
</div>
</div>
</Example>
```js
const picker = new Litepicker({
element: document.getElementById('datepicker-inline'),
inlineMode: true,
});
```
Inline calendars take the same options as popups:
<Example>
<div data-bs-toggle="datepicker" data-bs-inline="true" data-bs-selection-mode="multiple-ranged" data-bs-display-months-count="2"></div>
</Example>
To submit the picked dates with a form, put a hidden input inside the calendar. Tabler writes the dates to it in `YYYY-MM-DD` format, separated by commas.
<Example>
<form onsubmit="return false">
<div data-bs-toggle="datepicker" data-bs-inline="true">
<input type="hidden" name="selected_date" />
</div>
<button type="submit" class="btn btn-primary mt-3">Submit</button>
</form>
</Example>
## JavaScript
### Custom navigation icons
### Via data attributes
Litepicker's default previous/next month buttons are plain arrows. Tabler replaces them with [Tabler Icons](/icons) chevrons through the `buttonText` option, which accepts HTML for each button.
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"`.
<CodeDocs name="datepicker-init" file="core/js/src/datepicker.ts" />
### Via JavaScript
Create the picker yourself when you need options that do not fit in an attribute, such as a date format or a callback:
```js
const picker = new Litepicker({
element: document.getElementById('datepicker-basic'),
buttonText: {
previousMonth: '<svg class="icon" ...><!-- chevron-left --></svg>',
nextMonth: '<svg class="icon" ...><!-- chevron-right --></svg>',
const datepicker = new tabler.Datepicker(document.getElementById('my-date'), {
selectionMode: 'multiple-ranged',
firstWeekday: 0,
dateFormat: { month: 'short', day: 'numeric', year: 'numeric' },
});
```
### Options
| Name | Type | Default | Description |
| --- | --- | --- | --- |
| `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. |
| `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'`. |
| `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'`. |
| `placement` | string | `'left'` | Where the popup lines up with the field: `'left'`, `'center'`, `'right'` or `'auto'`. |
| `datepickerTheme` | string | `null` | Force the popup's theme to `'light'`, `'dark'` or `'auto'`. By default it follows the nearest `[data-bs-theme]`. |
| `vcpOptions` | object | `{}` | Any [Vanilla Calendar Pro option](https://vanilla-calendar.pro/docs/reference/settings), passed straight through. |
Use `vcpOptions` for everything the plugin can do that Tabler does not expose, for example disabling weekends or adding a time picker:
```js
const datepicker = new tabler.Datepicker(element, {
vcpOptions: {
disableDatesPast: true,
disableWeekdays: [0, 6],
selectionTimeMode: 24,
},
});
```
Litepicker uses `buttonText` as the buttons' `innerHTML`, and the icon SVGs are `aria-hidden`, so the generated buttons have no accessible name on their own. Litepicker also re-renders these buttons on every open and every month change, so re-apply the labels on each `render` event instead of once at init:
### Methods
| Method | Description |
| --- | --- |
| `show()` | Opens the popup. Does nothing for an inline calendar. |
| `hide()` | Closes the popup. |
| `toggle()` | Opens or closes the popup. |
| `getSelectedDates()` | Returns the picked dates as an array of `YYYY-MM-DD` strings. |
| `setSelectedDates(dates)` | Sets the picked dates. Takes an array of `YYYY-MM-DD` strings. |
| `dispose()` | Removes the calendar and the instance. |
| `calendar` | The Vanilla Calendar Pro instance, for anything the methods above do not cover. |
| `Datepicker.getInstance(element)` | Returns the instance for an element, or `null`. |
| `Datepicker.getOrCreateInstance(element)` | Returns the instance, or creates one. |
### Events
| Event | Description |
| --- | --- |
| `show.bs.datepicker` | Fires before the popup opens. Call `preventDefault()` to keep it closed. |
| `shown.bs.datepicker` | Fires once the popup is open. |
| `hide.bs.datepicker` | Fires before the popup closes. Call `preventDefault()` to keep it open. |
| `hidden.bs.datepicker` | Fires once the popup is closed. |
| `change.bs.datepicker` | Fires when the user picks a date. The event has `dates`, an array of `YYYY-MM-DD` strings, and `event`, the click. |
```js
picker.on('render', () => {
picker.ui?.querySelector('.button-previous-month')?.setAttribute('aria-label', 'Previous month');
picker.ui?.querySelector('.button-next-month')?.setAttribute('aria-label', 'Next month');
document.getElementById('my-date').addEventListener('change.bs.datepicker', (event) => {
console.log(event.dates);
});
```
### Common options
These are the options you will need most often. Litepicker has more, and they are listed in its [documentation](https://litepicker.com/#option).
| Option | What it does |
| --- | --- |
| `element` | The input or element Litepicker attaches to. |
| `inlineMode` | Renders the calendar on the page instead of in a popover. |
| `singleMode` | `true` picks one date; `false` picks a date range. |
| `format` | Format of the date shown in the field, for example `YYYY-MM-DD`. |
| `minDate` / `maxDate` | Limits the range of selectable dates. |
| `lockDays` | List of dates users cannot pick. |
| `numberOfColumns` / `numberOfMonths` | Shows more than one month at a time. |
| `buttonText` | HTML for the previous/next month and other buttons. |
## Accessibility
- Always add a `<label>` linked to the input with `for` and `id`. The calendar icon alone does not say what the field is for.
- Litepicker's month-navigation buttons carry no accessible name on their own - set `aria-label` on the `render` event, as shown above.
- Keep the typed value working. Users can type a date directly into the input; the picker is a shortcut, not the only way to set the value.
- The picker can be operated with the keyboard: focus the input to open it, use arrow keys to move between days, and <kbd>Enter</kbd> to pick a date.
- Always add a `<label>` linked to the input with `for` and `id`. A calendar icon alone does not say what the field is for.
- Vanilla Calendar Pro marks the calendar up as a grid with `role="grid"`, `role="row"` and `role="gridcell"`, labels every date and handles the arrow keys. Give an inline calendar an accessible name with `aria-label` when nothing around it names it.
- The popup closes on <kbd>Escape</kbd>, on a click outside, and when focus moves to something outside the calendar, so keyboard users are never trapped in it.
- Users can still type a date into the field. The picker is a shortcut, not the only way to set the value.
- The calendar respects `prefers-reduced-motion` and drops its animations for users who ask for that.
<script>{`
window.addEventListener('load', function () {
if (typeof Litepicker === 'undefined') return;
## Migration from Litepicker
function initPicker(el, options) {
if (!el) return;
var picker = new Litepicker(Object.assign({ element: el }, options));
picker.on('render', function () {
var prev = picker.ui && picker.ui.querySelector('.button-previous-month');
var next = picker.ui && picker.ui.querySelector('.button-next-month');
if (prev) prev.setAttribute('aria-label', 'Previous month');
if (next) next.setAttribute('aria-label', 'Next month');
});
return picker;
}
Tabler 1.5 and older used [Litepicker](https://github.com/wakirin/Litepicker), which has been archived since 2023. It is no longer shipped in `dist/libs`, and its styles are gone from `tabler-vendors.css`. Load Vanilla Calendar Pro instead and switch the markup as shown below.
initPicker(document.getElementById('datepicker-overview'));
initPicker(document.getElementById('datepicker-basic'));
initPicker(document.getElementById('datepicker-icon'));
initPicker(document.getElementById('datepicker-icon-prepend'));
initPicker(document.getElementById('datepicker-inline'), { inlineMode: true });
});
`}</script>
| Litepicker | Datepicker |
| --- | --- |
| `new Litepicker({ element })` | `data-bs-toggle="datepicker"` on the element |
| `inlineMode: true` | `data-bs-inline="true"` |
| `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 |
| `picker.on('selected', …)` | `change.bs.datepicker` event |
## SCSS variables
The calendar grid and the popup surface have separate variables. Override the `--tblr-calendar-*` custom properties to restyle every calendar, inline or popup. The `--tblr-datepicker-*` ones only apply to the popup.
<CodeDocs name="datepicker-variables" file="core/scss/_variables.scss" />
+8 -8
View File
@@ -165,9 +165,6 @@ importers:
list.js:
specifier: ^2.3.1
version: 2.3.1
litepicker:
specifier: ^2.0.12
version: 2.0.12
nouislider:
specifier: ^15.8.1
version: 15.8.1
@@ -201,6 +198,9 @@ importers:
typescript:
specifier: ^7.0.2
version: 7.0.2
vanilla-calendar-pro:
specifier: ^3.3.2
version: 3.3.2
vitest:
specifier: 4.1.11
version: 4.1.11(@types/node@26.4.0)(@vitest/browser-playwright@4.1.11)(@vitest/coverage-istanbul@4.1.11)(esbuild@0.28.1)(sass@1.103.1)(terser@5.51.2)(tsx@4.23.13)(yaml@2.9.0)
@@ -3239,9 +3239,6 @@ packages:
resolution: {integrity: sha512-jnmm7DYpKtH3DxtO1E2VNCC9Gp7Wrp/FWA2JxQrZUhVJ2RCQBd57pCN6W5w6jpsfWZV0PCAbTX2NOPgyFeeZZg==}
engines: {node: ^6.0 || ^8.0 || ^10.0 || ^12.0 || >=14}
litepicker@2.0.12:
resolution: {integrity: sha512-1N0sZJca/eALvS1hbJsVqKXwJAmp0BsJn73kZKWGGLjZkvjKr7HXGPV+/shu60aEY/JXoOg2XUmo7hbdIOsFpQ==}
loadjs@4.3.0:
resolution: {integrity: sha512-vNX4ZZLJBeDEOBvdr2v/F+0aN5oMuPu7JTqrMwp+DtgK+AryOlpy6Xtm2/HpNr+azEa828oQjOtWsB6iDtSfSQ==}
@@ -4565,6 +4562,9 @@ packages:
validate-npm-package-license@3.0.4:
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
vanilla-calendar-pro@3.3.2:
resolution: {integrity: sha512-RaLpDtvQFeKi0ajOpGBedLNrr0YoiYAF+0sppk4XZV/U57AuihJIhw8h2whUvIG1ZSR3LrdSXk3lHLkCPGlHvg==}
vfile-location@5.0.3:
resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==}
@@ -7791,8 +7791,6 @@ snapshots:
dependencies:
string-natural-compare: 2.0.3
litepicker@2.0.12: {}
loadjs@4.3.0: {}
locate-path@5.0.0:
@@ -9531,6 +9529,8 @@ snapshots:
spdx-correct: 3.2.0
spdx-expression-parse: 3.0.1
vanilla-calendar-pro@3.3.2: {}
vfile-location@5.0.3:
dependencies:
'@types/unist': 3.0.3
+211
View File
@@ -0,0 +1,211 @@
---
import DefaultLayout from '@shared/layouts/DefaultLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import CardTitle from '@ui/CardTitle.astro'
import CardSubtitle from '@ui/CardSubtitle.astro'
import Datepicker from '@ui/Datepicker.astro'
import DocsLink from '@ui/DocsLink.astro'
import Icon from '@ui/Icon.astro'
import CaptureScript from '@shared/components/CaptureScript.astro'
const year = new Date().getFullYear()
const rangeStart = `${year}-06-10`
const rangeEnd = `${year}-06-18`
---
<DefaultLayout title="Date picker" pageMenu="plugins.datepicker" pageLibs={['vanilla-calendar-pro']} description="A date picker opens a calendar under a field or a button and writes the picked date back into it.">
<DocsLink slot="page-header-actions" path="/ui/plugins/date-picker" />
<div class="row row-cards">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Basic</CardTitle>
<CardSubtitle>Add <code>data-bs-toggle="datepicker"</code> to a text input. The calendar opens on focus.</CardSubtitle>
<label class="form-label" for="datepicker-basic">Date</label>
<Datepicker id="basic" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>With icon</CardTitle>
<CardSubtitle>Wrap the field in <code>.input-icon</code>. The popup lines up with the wrapper, not the input.</CardSubtitle>
<label class="form-label" for="datepicker-icon">Date</label>
<Datepicker id="icon" layout="icon" class="mb-3" />
<label class="form-label" for="datepicker-icon-prepend">Date</label>
<Datepicker id="icon-prepend" layout="icon-prepend" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Initial value</CardTitle>
<CardSubtitle>A <code>value</code> in <code>YYYY-MM-DD</code> is shown on load and the calendar opens on that month.</CardSubtitle>
<label class="form-label" for="datepicker-value">Birth date</label>
<Datepicker id="value" value="1990-06-20" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Min and max</CardTitle>
<CardSubtitle>Limit the pickable dates with <code>data-bs-date-min</code> and <code>data-bs-date-max</code>.</CardSubtitle>
<label class="form-label" for="datepicker-min-max">Event date ({year} only)</label>
<Datepicker id="min-max" dateMin={`${year}-01-01`} dateMax={`${year}-12-31`} placeholder={`Select a date in ${year}`} />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Date range</CardTitle>
<CardSubtitle>Set <code>data-bs-selection-mode="multiple-ranged"</code> to pick a start and an end date.</CardSubtitle>
<label class="form-label" for="datepicker-range">Stay</label>
<Datepicker id="range" selectionMode="multiple-ranged" selectedDates={[rangeStart, rangeEnd]} placeholder="Select start and end" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Multiple dates</CardTitle>
<CardSubtitle>Set <code>data-bs-selection-mode="multiple"</code> to pick any number of dates.</CardSubtitle>
<label class="form-label" for="datepicker-multiple">Days off</label>
<Datepicker id="multiple" selectionMode="multiple" placeholder="Select dates" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Two months</CardTitle>
<CardSubtitle>Add <code>data-bs-display-months-count="2"</code> to show two months side by side.</CardSubtitle>
<label class="form-label" for="datepicker-two-months">Stay</label>
<Datepicker id="two-months" selectionMode="multiple-ranged" months={2} placeholder="Select start and end" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Week starts on Sunday</CardTitle>
<CardSubtitle>Set <code>data-bs-first-weekday="0"</code>. The default is Monday.</CardSubtitle>
<label class="form-label" for="datepicker-sunday">Date</label>
<Datepicker id="sunday" firstWeekday={0} />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Placement</CardTitle>
<CardSubtitle>Align the popup with <code>data-bs-placement</code>: left, center, right or auto.</CardSubtitle>
<label class="form-label" for="datepicker-center">Centered</label>
<Datepicker id="center" placement="center" class="mb-3" />
<label class="form-label" for="datepicker-right">Right aligned</label>
<Datepicker id="right" placement="right" />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Button trigger</CardTitle>
<CardSubtitle>Any element works as a trigger. Mark the text with <code>data-bs-datepicker-display</code> so the icon stays.</CardSubtitle>
<div class="btn-list">
<button type="button" class="btn" data-bs-toggle="datepicker">
<Icon name="calendar" />
<span data-bs-datepicker-display>Select date</span>
</button>
<button type="button" class="btn" data-bs-toggle="datepicker" data-bs-selection-mode="multiple-ranged">
<Icon name="calendar-event" />
<span data-bs-datepicker-display>Select range</span>
</button>
</div>
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Events</CardTitle>
<CardSubtitle>Listen to <code>change.bs.datepicker</code> to read the picked dates as <code>YYYY-MM-DD</code>.</CardSubtitle>
<label class="form-label" for="datepicker-events">Date</label>
<Datepicker id="events" selectionMode="multiple-ranged" placeholder="Select start and end" />
<div class="form-text" id="datepicker-events-output">No dates picked yet.</div>
</CardBody>
</Card>
</div>
</div>
<div class="row row-cards mt-4">
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Inline calendar</CardTitle>
<CardSubtitle>Add <code>data-bs-inline="true"</code> to render the calendar in place. It takes the card as its surface.</CardSubtitle>
<Datepicker id="inline" inline />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Inline range</CardTitle>
<CardSubtitle>Inline calendars take the same options. This one picks a start and an end date.</CardSubtitle>
<Datepicker id="inline-range" inline selectionMode="multiple-ranged" selectedDates={[rangeStart, rangeEnd]} />
</CardBody>
</Card>
</div>
<div class="col-md-6 col-lg-4">
<Card>
<CardBody>
<CardTitle>Inline in a form</CardTitle>
<CardSubtitle>A hidden input inside the calendar submits the picked dates with the form.</CardSubtitle>
<form onsubmit="return false">
<Datepicker id="inline-form" inline name="selected_date" />
<button type="submit" class="btn btn-primary mt-3">Submit</button>
</form>
</CardBody>
</Card>
</div>
<div class="col-md-12 col-lg-8">
<Card>
<CardBody>
<CardTitle>Inline with two months</CardTitle>
<CardSubtitle>Add <code>data-bs-display-months-count="2"</code> for a wide calendar.</CardSubtitle>
<Datepicker id="inline-two-months" inline selectionMode="multiple-ranged" months={2} />
</CardBody>
</Card>
</div>
</div>
<CaptureScript>
<!-- BEGIN DATEPICKER EVENTS SCRIPT -->
<script is:inline>
document.getElementById('datepicker-events').addEventListener('change.bs.datepicker', (event) => {
const output = document.getElementById('datepicker-events-output')
output.textContent = event.dates.length ? `Picked: ${event.dates.join(', ')}` : 'No dates picked yet.'
})
</script>
<!-- END DATEPICKER EVENTS SCRIPT -->
</CaptureScript>
</DefaultLayout>
+1 -1
View File
@@ -52,7 +52,7 @@ const rows = [
]
---
<DefaultLayout title="Form elements" pageMenu="form.elements" pageLibs={['nouislider', 'litepicker', 'tom-select', 'imask']}>
<DefaultLayout title="Form elements" pageMenu="form.elements" pageLibs={['nouislider', 'vanilla-calendar-pro', 'tom-select', 'imask']}>
<DocsLink slot="page-header-actions" path="/ui/forms/elements" />
<div class="row row-cards">
<div class="col-12">
+1 -1
View File
@@ -20,7 +20,7 @@ import DocsLink from '@ui/DocsLink.astro'
const countries = flags as { name: string }[]
---
<DefaultLayout title="Form layout" pageHeader="Form Layout" pageMenu="form.layout" pageLibs={['litepicker']}>
<DefaultLayout title="Form layout" pageHeader="Form Layout" pageMenu="form.layout" pageLibs={['vanilla-calendar-pro']}>
<DocsLink slot="page-header-actions" path="/ui/forms" />
<div class="row row-cards row-cols-1 row-cols-md-3">
+1 -1
View File
@@ -30,7 +30,7 @@ import DocsLink from '@ui/DocsLink.astro'
const cardClass = 'position-relative rounded d-block bg-surface-backdrop py-6 w-auto h-auto z-0'
---
<DefaultLayout title="Modals" pageMenu="base.modals" pageLibs={['signature_pad', 'hugerte', 'litepicker', 'tom-select']} description="Modals interrupt the page to ask for input, confirmation, or a focused action.">
<DefaultLayout title="Modals" pageMenu="base.modals" pageLibs={['signature_pad', 'hugerte', 'vanilla-calendar-pro', 'tom-select']} description="Modals interrupt the page to ask for input, confirmation, or a focused action.">
<DocsLink slot="page-header-actions" path="/ui/components/modal" />
<Card>
+60
View File
@@ -0,0 +1,60 @@
---
// The Datepicker plugin added in 1.6: a booking form field with the calendar
// open under it, two months side by side and a stay already picked. The popup
// is opened here rather than left to a focus the capture tool cannot make, and
// the fake cursor rests on a day past the range so the hover tint is part of
// the picture.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import Card from '@ui/Card.astro'
import CardBody from '@ui/CardBody.astro'
import Datepicker from '@ui/Datepicker.astro'
// The calendar opens on the month of the first date, so the picture is the
// same whenever it is taken.
const stay = ['2026-06-10', '2026-06-18']
const hovered = '2026-06-25'
---
<ScreenshotLayout title="Date picker" columns={2} pageLibs={['vanilla-calendar-pro']}>
<Card>
<CardBody>
<div class="row g-3">
<div class="col-12">
<label class="form-label" for="datepicker-stay">Stay</label>
<Datepicker id="stay" layout="icon" selectionMode="multiple-ranged" months={2} selectedDates={stay} placeholder="Select check-in and check-out" />
</div>
</div>
</CardBody>
</Card>
<!-- The popup hangs below the card, so the card moves up to keep the pair
centred in the frame and the calendar clear of the logo. -->
<style is:inline>
[data-screenshot-target] {
margin-bottom: 20rem;
}
</style>
<script is:inline define:vars={{ hovered }}>
document.addEventListener('DOMContentLoaded', () => {
const input = document.getElementById('datepicker-stay')
if (!input || !window.tabler?.Datepicker) return
const datepicker = window.tabler.Datepicker.getOrCreateInstance(input)
datepicker.show()
// The plugin builds and positions the popup in a timeout of its own, so
// the cursor and the hover tint go in one tick later, once the days exist.
setTimeout(() => {
const day = document.querySelector(`[data-vc-date="${hovered}"] [data-vc-date-btn]`)
if (!day) return
day.style.setProperty('--tblr-calendar-day-bg', 'var(--tblr-calendar-day-hover-bg)')
day.style.position = 'relative'
const cursor = document.createElement('span')
cursor.className = 'cursor'
day.append(cursor)
}, 50)
})
</script>
</ScreenshotLayout>
+2 -2
View File
@@ -1,13 +1,13 @@
---
// The New task modal added in 1.5. Rendered inline (ModalInline + `show`) so a
// static capture shows the open dialog; litepicker and tom-select back the due
// static capture shows the open dialog; the datepicker and tom-select back the due
// date and the assignee/tags selects.
import ScreenshotLayout from '../layouts/ScreenshotLayout.astro'
import ModalInline from '@shared/components/modals/ModalInline.astro'
import NewTaskModalContent from '@shared/components/modals/NewTaskModalContent.astro'
---
<ScreenshotLayout title="New task modal" columns={2} pageLibs={['litepicker', 'tom-select']}>
<ScreenshotLayout title="New task modal" columns={2} pageLibs={['vanilla-calendar-pro', 'tom-select']}>
<ModalInline class="position-relative rounded d-block w-auto h-auto z-0" modalId="new-task" size="lg" show>
<NewTaskModalContent />
</ModalInline>
+5
View File
@@ -503,6 +503,11 @@
"url": "colorpicker.html",
"title": "Color picker"
},
"datepicker": {
"url": "datepicker.html",
"title": "Date picker",
"badge": "New"
},
"datatables": {
"url": "datatables.html",
"title": "Datatables"
+7 -7
View File
@@ -75,13 +75,6 @@
"description": "Adds search, sort, and flexibility to tables, lists, and various HTML elements.",
"license": "MIT"
},
{
"name": "Litepicker",
"npm": "litepicker",
"url": "https://github.com/wakirin/Litepicker",
"description": "Lightweight, no-dependency date range picker.",
"license": "MIT"
},
{
"name": "Mapbox GL JS",
"lib": "mapbox",
@@ -151,5 +144,12 @@
"url": "https://mattboldt.com/demos/typed-js/",
"description": "JavaScript library that types out sentences in a human-like manner.",
"license": "MIT"
},
{
"name": "Vanilla Calendar Pro",
"npm": "vanilla-calendar-pro",
"url": "https://vanilla-calendar.pro/",
"description": "Lightweight, dependency-free calendar and date picker with keyboard navigation and themes.",
"license": "MIT"
}
]
+70 -91
View File
@@ -1,104 +1,83 @@
---
import Icon from './Icon.astro';
import { iconSvg, iconSourceComment } from '@shared/lib/svg';
import CaptureScript from '../components/CaptureScript.astro';
import Icon from './Icon.astro'
// A text field or inline block that Tabler's Datepicker plugin (in tabler.js)
// turns into a calendar. The plugin wraps Vanilla Calendar Pro, which the page
// loads through the vanilla-calendar-pro entry of pageLibs.
interface Props {
id?: string;
value?: string;
class?: string;
/** input-icon layout; omit for a plain input */
layout?: 'icon' | 'icon-prepend' | undefined;
inline?: boolean;
id?: string
/** initial date as `YYYY-MM-DD` */
value?: string
class?: string
placeholder?: string
/** input-icon layout; omit for a plain input */
layout?: 'icon' | 'icon-prepend' | undefined
/** render the calendar in place instead of a popup */
inline?: boolean
/** picking one date, any number of dates, or a start and an end */
selectionMode?: 'single' | 'multiple' | 'multiple-ranged'
/** months shown side by side */
months?: number
/** preselected dates as `YYYY-MM-DD`; a range is its start and end */
selectedDates?: string[]
dateMin?: string
dateMax?: string
/** 0 = Sunday, 1 = Monday */
firstWeekday?: 0 | 1 | 2 | 3 | 4 | 5 | 6
placement?: 'left' | 'center' | 'right' | 'auto'
/** name of the hidden input an inline calendar submits with a form */
name?: string
}
const {
id,
value,
class: className,
layout,
inline,
} = Astro.props;
id,
value,
class: className,
placeholder = 'Select a date',
layout,
inline = false,
selectionMode,
months,
selectedDates,
dateMin,
dateMax,
firstWeekday,
placement,
name,
} = Astro.props
const placeholder = 'Select a date';
const datepickerId = id ? `datepicker-${id}` : undefined
const chevronLeft = `${iconSourceComment('chevron-left')}\n\t${iconSvg('chevron-left') ?? ''}`;
const chevronRight = `${iconSourceComment('chevron-right')}\n\t${iconSvg('chevron-right') ?? ''}`;
const dataAttributes = {
'data-bs-toggle': 'datepicker',
'data-bs-inline': inline ? 'true' : undefined,
'data-bs-selection-mode': selectionMode,
'data-bs-display-months-count': months,
'data-bs-selected-dates': selectedDates ? JSON.stringify(selectedDates) : undefined,
'data-bs-date-min': dateMin,
'data-bs-date-max': dateMax,
'data-bs-first-weekday': firstWeekday,
'data-bs-placement': placement,
}
const datepickerId = id ? `datepicker-${id}` : undefined;
---
{
id &&
(inline ? (
<div class="datepicker-inline" id={`datepicker-${id}`} />
) : layout === 'icon' ? (
<div class:list={['input-icon', className]}>
<input
class="form-control"
placeholder={placeholder}
id={`datepicker-${id}`}
value={value}
/>
<span class="input-icon-addon"><Icon name="calendar" /></span>
</div>
) : layout === 'icon-prepend' ? (
<div class:list={['input-icon', className]}>
<span class="input-icon-addon"><Icon name="calendar" /></span>
<input
class="form-control"
placeholder={placeholder}
id={`datepicker-${id}`}
value={value}
/>
</div>
) : (
<input
class:list={['form-control', className]}
placeholder={placeholder}
id={`datepicker-${id}`}
value={value}
/>
))
}
{
id && (
<CaptureScript>
<!-- BEGIN DATEPICKER -->
<script is:inline define:vars={{ datepickerId, chevronLeft, chevronRight, inline }}>
function initDatepicker() {
window.tabler_datepicker ??= {};
if (!window.Litepicker) return;
const picker = new Litepicker({
element: document.getElementById(datepickerId),
buttonText: {
previousMonth: chevronLeft,
nextMonth: chevronRight,
},
...(inline ? { inlineMode: true } : {}),
});
// Litepicker sets buttonText as the buttons' innerHTML, and the icon SVGs are
// aria-hidden, so the generated month-nav buttons otherwise have no accessible
// name. Litepicker also re-renders these buttons (new DOM nodes) on every open
// and every month change, so re-apply the labels on each "render" event instead
// of labeling once at init.
// Litepicker builds the popover lazily, so label every calendar on the page, not only picker.ui.
const labelMonthButtons = () => {
for (const button of document.querySelectorAll('.litepicker .button-previous-month')) button.setAttribute('aria-label', 'Previous month');
for (const button of document.querySelectorAll('.litepicker .button-next-month')) button.setAttribute('aria-label', 'Next month');
};
picker.on('render', labelMonthButtons);
labelMonthButtons();
window.tabler_datepicker[datepickerId] = picker;
}
document.readyState !== 'loading' ? initDatepicker() : document.addEventListener('DOMContentLoaded', initDatepicker, { once: true });
</script>
<!-- END DATEPICKER -->
</CaptureScript>
)
inline ? (
<div class={className} id={datepickerId} {...dataAttributes}>
{name && <input type="hidden" name={name} />}
</div>
) : layout === 'icon' ? (
<div class:list={['input-icon', className]}>
<input type="text" class="form-control" id={datepickerId} value={value} placeholder={placeholder} autocomplete="off" {...dataAttributes} />
<span class="input-icon-addon"><Icon name="calendar" /></span>
</div>
) : layout === 'icon-prepend' ? (
<div class:list={['input-icon', className]}>
<span class="input-icon-addon"><Icon name="calendar" /></span>
<input type="text" class="form-control" id={datepickerId} value={value} placeholder={placeholder} autocomplete="off" {...dataAttributes} />
</div>
) : (
<input type="text" class:list={['form-control', className]} id={datepickerId} value={value} placeholder={placeholder} autocomplete="off" {...dataAttributes} />
)
}