diff --git a/.agents/skills/core-scss/SKILL.md b/.agents/skills/core-scss/SKILL.md index e2b503beb..45ac9741a 100644 --- a/.agents/skills/core-scss/SKILL.md +++ b/.agents/skills/core-scss/SKILL.md @@ -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`. diff --git a/.build/css-var-prefix.ts b/.build/css-var-prefix.ts index 85020287a..2b5c0b7e7 100644 --- a/.build/css-var-prefix.ts +++ b/.build/css-var-prefix.ts @@ -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 diff --git a/.changeset/datepicker-plugin.md b/.changeset/datepicker-plugin.md new file mode 100644 index 000000000..3fce3c91a --- /dev/null +++ b/.changeset/datepicker-plugin.md @@ -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`. diff --git a/README.md b/README.md index f5e0ed8d1..76530d051 100644 --- a/README.md +++ b/README.md @@ -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/ diff --git a/core/README.md b/core/README.md index e1e9a394a..07bd5696f 100644 --- a/core/README.md +++ b/core/README.md @@ -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 diff --git a/core/js/src/datepicker.ts b/core/js/src/datepicker.ts new file mode 100644 index 000000000..b3a860da2 --- /dev/null +++ b/core/js/src/datepicker.ts @@ -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 & Record + +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 = { + 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 { + 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 { + if (this._config.inline) { + return Promise.resolve() // Inline calendars are always visible + } + + return this._isShown ? this.hide() : this.show() + } + + async show(): Promise { + 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 { + 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(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 , 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 , 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 } diff --git a/core/js/src/global.d.ts b/core/js/src/global.d.ts index bcf19aa9a..1377da500 100644 --- a/core/js/src/global.d.ts +++ b/core/js/src/global.d.ts @@ -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) => SortableInstance } diff --git a/core/js/tabler.ts b/core/js/tabler.ts index f4cbd1cde..65d51c9f6 100644 --- a/core/js/tabler.ts +++ b/core/js/tabler.ts @@ -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' diff --git a/core/js/tests/unit/datepicker.spec.ts b/core/js/tests/unit/datepicker.spec.ts new file mode 100644 index 000000000..58668c061 --- /dev/null +++ b/core/js/tests/unit/datepicker.spec.ts @@ -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 , 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 = '' + + 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 = '' + + const instance = new Datepicker(input()) + + expect(instance.calendar).toBeNull() + }) + + it('should detect a button trigger and use it as the display element', () => { + fixtureEl.innerHTML = '' + + 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 = '' + + const instance = new Datepicker(fixtureEl.querySelector('button')!) + + expect(instance._displayElement).toBe(fixtureEl.querySelector('span')) + }) + + it('should read config from data attributes', () => { + fixtureEl.innerHTML = '' + + 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 = '' + + 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 = `` + + 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 = '' + + const instance = new Datepicker(input()) + + expect(instance.getSelectedDates()).toEqual(['2020-06-20']) + }) + + it('should position the popup against the .input-icon wrapper', () => { + fixtureEl.innerHTML = '
' + + 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 = '
' + + 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 = '' + 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().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 = '' + + 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 = '
' + + 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 = '' + + 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 = '' + + 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 = '' + + 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().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 = '' + + 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) + }) + }) +}) diff --git a/core/libs.json b/core/libs.json index f6766d67a..90813172d 100644 --- a/core/libs.json +++ b/core/libs.json @@ -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"] } } diff --git a/core/package.json b/core/package.json index d7d7b679a..bab3d7c18 100644 --- a/core/package.json +++ b/core/package.json @@ -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" } } diff --git a/core/scss/_core.scss b/core/scss/_core.scss index 47a6d3a8c..6e313cf32 100644 --- a/core/scss/_core.scss +++ b/core/scss/_core.scss @@ -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'; diff --git a/core/scss/_variables.scss b/core/scss/_variables.scss index 65be49846..a02e34142 100644 --- a/core/scss/_variables.scss +++ b/core/scss/_variables.scss @@ -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; diff --git a/core/scss/tabler-vendors.scss b/core/scss/tabler-vendors.scss index 0db311bf5..b29923ab0 100644 --- a/core/scss/tabler-vendors.scss +++ b/core/scss/tabler-vendors.scss @@ -1,7 +1,6 @@ @forward 'config'; @forward 'vendor/nouislider'; -@forward 'vendor/litepicker'; @forward 'vendor/tom-select'; @forward 'vendor/apexcharts'; @forward 'vendor/jsvectormap'; diff --git a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap index 42ad5af96..c9588f5c3 100644 --- a/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap +++ b/core/scss/tests/__snapshots__/css-var-prefix.test.mjs.snap @@ -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", diff --git a/core/scss/ui/_datepicker.scss b/core/scss/ui/_datepicker.scss new file mode 100644 index 000000000..066a405a6 --- /dev/null +++ b/core/scss/ui/_datepicker.scss @@ -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,"), $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 diff --git a/core/scss/vendor/_litepicker.scss b/core/scss/vendor/_litepicker.scss deleted file mode 100644 index 674c9c6a2..000000000 --- a/core/scss/vendor/_litepicker.scss +++ /dev/null @@ -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)); - } - } -} diff --git a/docs/content/ui/plugins/date-picker.mdx b/docs/content/ui/plugins/date-picker.mdx index 97f336b0a..a65822b37 100644 --- a/docs/content/ui/plugins/date-picker.mdx +++ b/docs/content/ui/plugins/date-picker.mdx @@ -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. - + + ## 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: - + -Or include it from a CDN: +Or include it from a CDN, before `tabler.js`: -`} /> +`} /> -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: - - +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. - - + + + -```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. +
- +
+### 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. + -
- - + + + + +### 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. + + + + + + +### 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. + + + + + + +### 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. + + + + + + +### 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. + + + + + + +### First day of the week + +The week starts on Monday. Set `data-bs-first-weekday="0"` to start it on Sunday. + + + + + + +### Placement + +The popup lines up with the left edge of the field. Set `data-bs-placement` to `center`, `right` or `auto` to change that. + + + + + + +### 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. + + +
+ +
+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. -
+
+
+
+
+
-```js -const picker = new Litepicker({ - element: document.getElementById('datepicker-inline'), - inlineMode: true, -}); -``` +Inline calendars take the same options as popups: + + +
+
+ +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. + + +
+
+ +
+ +
+
## 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"`. + + + +### 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: '', - nextMonth: '', +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 `