-
-
-
+ openYear: () => void
+ setYear: ($event: number) => void
+ }
+ selectorMode?: boolean
+ selectorFocus?: SelectorFocus
+ pickerViewMode?: PickerViewMode
+ panelName?: SelectionPanel
+}>(), {
+ selectorMode: false,
+ selectorFocus: 'month',
+ pickerViewMode: 'calendar',
+ panelName: 'previous',
+})
+
+const emit = defineEmits<{
+ (e: 'enter-selector-mode', payload: { panel: SelectionPanel; focus: SelectorFocus }): void
+ (e: 'toggle-picker-view', payload: { panel: SelectionPanel; focus: SelectorFocus }): void
+ (e: 'step-month', payload: { panel: SelectionPanel; delta: -1 | 1 }): void
+}>()
+
+const SIDE_NAV_BUTTON_CLASS
+ = 'p-1.5 rounded-full bg-white text-vtd-secondary-600 transition-colors border border-transparent hover:bg-vtd-secondary-100 hover:text-vtd-secondary-900 focus:bg-vtd-primary-50 focus:text-vtd-secondary-900 focus:border-vtd-primary-300 focus:ring-3 focus:ring-vtd-primary-500/10 focus:outline-hidden dark:bg-vtd-secondary-800 dark:text-vtd-secondary-300 dark:hover:bg-vtd-secondary-700 dark:hover:text-vtd-secondary-300 dark:focus:bg-vtd-secondary-600/50 dark:focus:text-vtd-secondary-100 dark:focus:border-vtd-primary-500 dark:focus:ring-vtd-primary-500/25'
+
+const isSelectorWheelView = computed(() => {
+ return props.selectorMode && props.pickerViewMode === 'selector'
+})
+
+function isMonthSideNavigation() {
+ return props.selectorMode || props.panel.calendar
+}
+
+function sideButtonAriaLabel(direction: 'previous' | 'next') {
+ const subject = isMonthSideNavigation() ? 'month' : 'year'
+ const verb = direction === 'previous' ? 'Previous' : 'Next'
+ return `${verb} ${subject}`
+}
+
+function sideButtonPath(direction: 'previous' | 'next') {
+ if (isMonthSideNavigation())
+ return direction === 'previous' ? 'M15 19l-7-7 7-7' : 'M9 5l7 7-7 7'
+ return direction === 'previous'
+ ? 'M11 19l-7-7 7-7m8 14l-7-7 7-7'
+ : 'M13 5l7 7-7 7M5 5l7 7-7 7'
+}
+
+function onHeaderValueClick(focus: SelectorFocus) {
+ const openLegacyPanel = () => {
+ if (focus === 'month')
+ props.calendar.openMonth()
+ else
+ props.calendar.openYear()
+ }
+
+ if (!props.selectorMode) {
+ openLegacyPanel()
+ return
+ }
+
+ if (props.pickerViewMode === 'selector') {
+ emit('toggle-picker-view', { panel: props.panelName, focus })
+ return
+ }
+
+ emit('enter-selector-mode', { panel: props.panelName, focus })
+}
+
+function onSelectorHeaderClick(focus: SelectorFocus = 'month') {
+ onHeaderValueClick(focus)
+}
+
+const selectorMonthTextRef = ref
(null)
+const selectorYearTextRef = ref(null)
+
+function resolveSelectorFocusFromClick(event: MouseEvent): SelectorFocus {
+ // Keyboard-triggered click events (Enter/Space) report detail=0.
+ if (event.detail === 0)
+ return props.selectorFocus
+
+ const target = event.target
+ if (target instanceof Node) {
+ if (selectorMonthTextRef.value?.contains(target))
+ return 'month'
+ if (selectorYearTextRef.value?.contains(target))
+ return 'year'
+ }
+
+ const currentTarget = event.currentTarget
+ if (currentTarget instanceof HTMLElement) {
+ const rect = currentTarget.getBoundingClientRect()
+ const clickX = event.clientX - rect.left
+ if (clickX >= 0 && clickX <= rect.width)
+ return clickX < (rect.width / 2) ? 'month' : 'year'
+ }
+
+ return props.selectorFocus
+}
+
+function onSelectorHeaderClickWithHeuristic(event: MouseEvent) {
+ onSelectorHeaderClick(resolveSelectorFocusFromClick(event))
+}
+
+function onSidePreviousClick() {
+ if (isSelectorWheelView.value) {
+ emit('step-month', { panel: props.panelName, delta: -1 })
+ return
+ }
+
+ if (isMonthSideNavigation()) {
+ props.calendar.onPrevious()
+ return
+ }
+ props.calendar.onPreviousYear()
+}
+
+function onSideNextClick() {
+ if (isSelectorWheelView.value) {
+ emit('step-month', { panel: props.panelName, delta: 1 })
+ return
+ }
+
+ if (isMonthSideNavigation()) {
+ props.calendar.onNext()
+ return
+ }
+ props.calendar.onNextYear()
+}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/components/Month.vue b/src/components/Month.vue
index a844a45..f34e116 100644
--- a/src/components/Month.vue
+++ b/src/components/Month.vue
@@ -1,25 +1,587 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
diff --git a/src/components/SelectorWheelStepButton.vue b/src/components/SelectorWheelStepButton.vue
new file mode 100644
index 0000000..2f6b38b
--- /dev/null
+++ b/src/components/SelectorWheelStepButton.vue
@@ -0,0 +1,36 @@
+
+
+
+
+
diff --git a/src/components/Year.vue b/src/components/Year.vue
index a6712d3..c0c36c6 100644
--- a/src/components/Year.vue
+++ b/src/components/Year.vue
@@ -1,24 +1,1087 @@
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
{{ yearAriaAnnouncement }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/index.css b/src/index.css
index 0d123d7..4de30bc 100644
--- a/src/index.css
+++ b/src/index.css
@@ -1,6 +1,7 @@
@import "tailwindcss";
@plugin "@tailwindcss/forms";
+@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-light-blue-50: var(--color-sky-50);
@@ -40,3 +41,160 @@
--color-vtd-secondary-950: var(--color-gray-950);
}
+@layer components {
+ .vtd-datepicker {
+ --vtd-calendar-range-preview-bg: rgb(224 242 254 / 60%);
+ --vtd-calendar-range-preview-bg-dark: rgb(55 65 81 / 50%);
+ --vtd-calendar-range-preview-edge-bg: var(--vtd-calendar-day-selected-bg);
+ --vtd-calendar-range-preview-edge-bg-dark: var(--vtd-calendar-day-selected-bg);
+ --vtd-calendar-day-focus-shadow: inset 0 0 0 2px var(--color-vtd-primary-500);
+ --vtd-calendar-day-selected-bg: var(--color-vtd-primary-500);
+ --vtd-calendar-day-selected-text: rgb(255 255 255 / 100%);
+ --vtd-calendar-day-selected-font-weight: 700;
+ --vtd-calendar-day-selected-border-width: 0px;
+ --vtd-calendar-day-selected-border-color: transparent;
+ --vtd-calendar-day-selected-shadow: none;
+ --vtd-calendar-day-selected-radius: 9999px;
+ --vtd-calendar-day-selected-start-radius: 9999px 0 0 9999px;
+ --vtd-calendar-day-selected-end-radius: 0 9999px 9999px 0;
+
+ --vtd-selector-wheel-cell-height: 40px;
+ --vtd-selector-month-font-family: inherit;
+ --vtd-selector-month-font-size: 0.875rem;
+ --vtd-selector-month-font-weight: 500;
+ --vtd-selector-month-line-height: 1.5rem;
+ --vtd-selector-month-text: rgb(163 163 163 / 100%);
+ --vtd-selector-month-cell-shadow: none;
+ --vtd-selector-month-hover-bg: rgb(14 165 233 / 10%);
+ --vtd-selector-month-hover-border: rgb(56 189 248 / 45%);
+ --vtd-selector-month-hover-border-width: 0.85px;
+ --vtd-selector-month-hover-text: rgb(14 116 144 / 100%);
+ --vtd-selector-month-selected-bg: rgb(14 165 233 / 13%);
+ --vtd-selector-month-selected-border: rgb(14 165 233 / 62%);
+ --vtd-selector-month-selected-border-width: 0.85px;
+ --vtd-selector-month-selected-text: rgb(56 189 248 / 100%);
+
+ --vtd-selector-year-hover-bg: rgb(14 165 233 / 10%);
+ --vtd-selector-year-hover-border: rgb(56 189 248 / 45%);
+ --vtd-selector-year-hover-border-width: 0.85px;
+ --vtd-selector-year-hover-text: rgb(14 116 144 / 100%);
+ --vtd-selector-year-selected-bg: rgb(14 165 233 / 13%);
+ --vtd-selector-year-selected-border: rgb(14 165 233 / 62%);
+ --vtd-selector-year-selected-border-width: 0.85px;
+ --vtd-selector-year-canvas-border-width-scale: 0.5;
+ --vtd-selector-year-selected-text: rgb(56 189 248 / 100%);
+ --vtd-selector-year-text: rgb(163 163 163 / 100%);
+ --vtd-selector-year-canvas-dpr: 4;
+ --vtd-selector-year-text-offset-y: 0px;
+ --vtd-selector-year-font-family: inherit;
+ --vtd-selector-year-font-size: 0.875rem;
+ --vtd-selector-year-font-weight: 500;
+ }
+
+ .dark .vtd-datepicker {
+ --vtd-calendar-day-focus-shadow: inset 0 0 0 2px var(--color-vtd-primary-400);
+ --vtd-selector-month-text: rgb(163 163 163 / 100%);
+ --vtd-selector-year-text: rgb(163 163 163 / 100%);
+ --vtd-selector-month-hover-bg: rgb(56 189 248 / 18%);
+ --vtd-selector-month-hover-border: rgb(56 189 248 / 55%);
+ --vtd-selector-month-hover-text: rgb(125 211 252 / 100%);
+ --vtd-selector-year-hover-bg: rgb(56 189 248 / 18%);
+ --vtd-selector-year-hover-border: rgb(56 189 248 / 55%);
+ --vtd-selector-year-hover-text: rgb(125 211 252 / 100%);
+ }
+
+ .vtd-datepicker [aria-label='Month selector'],
+ .vtd-datepicker [aria-label='Year selector'] {
+ overscroll-behavior-y: contain;
+ scrollbar-gutter: stable;
+ }
+
+ .vtd-datepicker .vtd-datepicker-range-preview {
+ background-color: var(--vtd-calendar-range-preview-bg);
+ transition: opacity 120ms ease-out, background-color 140ms ease-out;
+ }
+
+ .dark .vtd-datepicker .vtd-datepicker-range-preview {
+ background-color: var(--vtd-calendar-range-preview-bg-dark);
+ }
+
+ .vtd-datepicker .vtd-datepicker-range-preview-edge {
+ background-color: var(--vtd-calendar-range-preview-edge-bg);
+ }
+
+ .dark .vtd-datepicker .vtd-datepicker-range-preview-edge {
+ background-color: var(--vtd-calendar-range-preview-edge-bg-dark);
+ }
+
+ .vtd-datepicker .vtd-datepicker-date:focus-visible {
+ box-shadow: var(--vtd-calendar-day-focus-shadow);
+ }
+
+ .vtd-datepicker .vtd-datepicker-date-selected {
+ background-color: var(--vtd-calendar-day-selected-bg);
+ color: var(--vtd-calendar-day-selected-text);
+ font-weight: var(--vtd-calendar-day-selected-font-weight);
+ border-style: solid;
+ border-width: var(--vtd-calendar-day-selected-border-width);
+ border-color: var(--vtd-calendar-day-selected-border-color);
+ box-shadow: var(--vtd-calendar-day-selected-shadow);
+ }
+
+ .vtd-datepicker .vtd-datepicker-date-selected-single {
+ border-radius: var(--vtd-calendar-day-selected-radius);
+ }
+
+ .vtd-datepicker .vtd-datepicker-date-selected-start {
+ border-radius: var(--vtd-calendar-day-selected-start-radius);
+ }
+
+ .vtd-datepicker .vtd-datepicker-date-selected-end {
+ border-radius: var(--vtd-calendar-day-selected-end-radius);
+ }
+
+ .vtd-datepicker [aria-label='Month selector'] {
+ scroll-snap-type: none;
+ scrollbar-width: none;
+ }
+
+ .vtd-datepicker [aria-label='Month selector']::-webkit-scrollbar {
+ display: none;
+ }
+
+ .vtd-datepicker [role='listbox'] button[aria-selected]:focus-visible {
+ outline: 0;
+ box-shadow: 0 0 0 2px var(--color-vtd-primary-300), 0 0 0 5px rgb(14 165 233 / 10%);
+ }
+
+ .dark .vtd-datepicker [role='listbox'] button[aria-selected]:focus-visible {
+ box-shadow: 0 0 0 2px var(--color-vtd-primary-500), 0 0 0 5px rgb(14 165 233 / 20%);
+ }
+
+ .vtd-datepicker [aria-label='Year selector'] button[aria-selected='true'] {
+ border-color: var(--color-vtd-primary-400);
+ box-shadow:
+ inset 0 0 0 1px rgb(56 189 248 / 40%),
+ 0 8px 18px -16px rgb(14 165 233 / 65%);
+ }
+
+ .dark .vtd-datepicker [aria-label='Year selector'] button[aria-selected='true'] {
+ border-color: rgb(56 189 248 / 45%);
+ box-shadow:
+ inset 0 0 0 1px rgb(56 189 248 / 45%),
+ 0 8px 18px -16px rgb(14 165 233 / 45%);
+ }
+
+ .vtd-datepicker .vtd-year-scrolling button {
+ transition: none !important;
+ box-shadow: none !important;
+ }
+
+ .vtd-datepicker .vtd-year-scrolling button[aria-selected='true'] {
+ border-color: var(--color-vtd-primary-400);
+ }
+
+ .dark .vtd-datepicker .vtd-year-scrolling button[aria-selected='true'] {
+ border-color: rgb(56 189 248 / 45%);
+ }
+
+}
diff --git a/tests/TODO.md b/tests/TODO.md
new file mode 100644
index 0000000..56f3ded
--- /dev/null
+++ b/tests/TODO.md
@@ -0,0 +1,5 @@
+# Testing TODOs
+
+- TODO: Add browser-level interaction tests (Playwright/Cypress/Puppeteer) for selector mode and range picker UX flows.
+- TODO: Cover keyboard focus-cycle behavior end-to-end (Tab/Shift+Tab, Escape, arrow navigation) in real browser conditions.
+- TODO: Cover wheel scrolling edge cases (fractional/boundary year mode, fast flicks, viewport resize, runtime theme token changes).
diff --git a/tests/setup.ts b/tests/setup.ts
new file mode 100644
index 0000000..5e1c5a7
--- /dev/null
+++ b/tests/setup.ts
@@ -0,0 +1,90 @@
+import { afterEach } from 'vitest'
+
+function createMockRect() {
+ return {
+ x: 0,
+ y: 0,
+ width: 100,
+ height: 40,
+ top: 0,
+ right: 100,
+ bottom: 40,
+ left: 0,
+ toJSON: () => ({}),
+ }
+}
+
+if (typeof window !== 'undefined' && !window.requestAnimationFrame) {
+ window.requestAnimationFrame = callback => window.setTimeout(() => callback(performance.now()), 0)
+ window.cancelAnimationFrame = handle => window.clearTimeout(handle)
+}
+
+Object.defineProperty(HTMLElement.prototype, 'scrollTo', {
+ configurable: true,
+ writable: true,
+ value(optionsOrX?: ScrollToOptions | number, y?: number) {
+ if (typeof optionsOrX === 'number') {
+ this.scrollLeft = optionsOrX
+ this.scrollTop = typeof y === 'number' ? y : this.scrollTop
+ return
+ }
+
+ if (optionsOrX && typeof optionsOrX === 'object') {
+ if (typeof optionsOrX.left === 'number')
+ this.scrollLeft = optionsOrX.left
+ if (typeof optionsOrX.top === 'number')
+ this.scrollTop = optionsOrX.top
+ }
+ },
+})
+
+Object.defineProperty(HTMLElement.prototype, 'getClientRects', {
+ configurable: true,
+ writable: true,
+ value(this: HTMLElement) {
+ const style = window.getComputedStyle(this)
+
+ if (!style || style.display === 'none' || style.visibility === 'hidden')
+ return [] as unknown as DOMRectList
+
+ return [createMockRect()] as unknown as DOMRectList
+ },
+})
+
+Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
+ configurable: true,
+ writable: true,
+ value() {
+ return {
+ setTransform: () => {},
+ clearRect: () => {},
+ beginPath: () => {},
+ moveTo: () => {},
+ arcTo: () => {},
+ closePath: () => {},
+ stroke: () => {},
+ save: () => {},
+ restore: () => {},
+ arc: () => {},
+ translate: () => {},
+ rotate: () => {},
+ fill: () => {},
+ fillText: () => {},
+ measureText: () => ({
+ width: 12,
+ actualBoundingBoxAscent: 8,
+ actualBoundingBoxDescent: 3,
+ }),
+ textAlign: 'center',
+ font: '500 14px sans-serif',
+ textBaseline: 'alphabetic',
+ lineWidth: 1,
+ strokeStyle: '#000',
+ fillStyle: '#000',
+ }
+ },
+})
+
+afterEach(() => {
+ document.body.innerHTML = ''
+})
diff --git a/tests/unit/calendar-keyboard-activation.spec.ts b/tests/unit/calendar-keyboard-activation.spec.ts
new file mode 100644
index 0000000..3d1e411
--- /dev/null
+++ b/tests/unit/calendar-keyboard-activation.spec.ts
@@ -0,0 +1,73 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import Calendar from '../../src/components/Calendar.vue'
+import {
+ atMouseOverKey,
+ betweenRangeClassesKey,
+ datepickerClassesKey,
+ isBetweenRangeKey,
+} from '../../src/keys'
+
+function createCalendarDate(key: string, day: number) {
+ return {
+ format: () => key,
+ toDate: () => new Date(`${key}T00:00:00.000Z`),
+ date: () => day,
+ week: () => 7,
+ hovered: () => false,
+ duration: () => false,
+ disabled: false,
+ inRange: false,
+ active: true,
+ today: false,
+ }
+}
+
+function mountCalendarForKeyboard() {
+ const date = createCalendarDate('2026-02-15', 15)
+ const calendar = {
+ date: () => [date],
+ month: 'Feb',
+ year: 2026,
+ years: () => [2025, 2026, 2027],
+ onPrevious: vi.fn(),
+ onNext: vi.fn(),
+ onPreviousYear: vi.fn(),
+ onNextYear: vi.fn(),
+ openMonth: vi.fn(),
+ setMonth: vi.fn(),
+ openYear: vi.fn(),
+ setYear: vi.fn(),
+ }
+
+ return mount(Calendar, {
+ props: {
+ calendar,
+ weeks: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
+ weekNumber: false,
+ asRange: false,
+ },
+ global: {
+ provide: {
+ [isBetweenRangeKey as symbol]: () => false,
+ [betweenRangeClassesKey as symbol]: () => '',
+ [datepickerClassesKey as symbol]: () => '',
+ [atMouseOverKey as symbol]: () => undefined,
+ },
+ },
+ })
+}
+
+describe('Calendar keyboard activation', () => {
+ it('activates the focused date on Enter and Space keys', async () => {
+ const wrapper = mountCalendarForKeyboard()
+ const button = wrapper.get('[data-date-key="2026-02-15"]')
+
+ await button.trigger('keydown', { key: 'Enter' })
+ await button.trigger('keydown', { key: ' ' })
+ await button.trigger('keydown', { key: 'Spacebar' })
+
+ const emissions = wrapper.emitted('updateDate') ?? []
+ expect(emissions).toHaveLength(3)
+ })
+})
diff --git a/tests/unit/calendar-today-range-style.spec.ts b/tests/unit/calendar-today-range-style.spec.ts
new file mode 100644
index 0000000..7ecf0c0
--- /dev/null
+++ b/tests/unit/calendar-today-range-style.spec.ts
@@ -0,0 +1,44 @@
+import dayjs from 'dayjs'
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import VueTailwindDatePicker from '../../src/VueTailwindDatePicker.vue'
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+describe('Calendar today styling in range preview', () => {
+ it('keeps today marker styling when today is inside an active range (non-endpoint)', async () => {
+ vi.useFakeTimers()
+ const startDate = dayjs().subtract(2, 'day').format('YYYY-MM-DD HH:mm:ss')
+ const endDate = dayjs().add(2, 'day').format('YYYY-MM-DD HH:mm:ss')
+ const todayKey = dayjs().format('YYYY-MM-DD')
+
+ const wrapper = mount(VueTailwindDatePicker, {
+ attachTo: document.body,
+ props: {
+ noInput: true,
+ useRange: true,
+ asSingle: true,
+ autoApply: true,
+ modelValue: {
+ startDate,
+ endDate,
+ },
+ },
+ })
+
+ vi.advanceTimersByTime(260)
+ await nextTick()
+ await nextTick()
+
+ const todayButton = wrapper.get(`[data-date-key="${todayKey}"]`)
+ const classNames = todayButton.attributes('class')
+
+ // Today should keep a distinct marker while inside the in-range preview.
+ expect(classNames).toContain('text-vtd-primary-500')
+ expect(classNames).toContain('rounded-full')
+ expect(classNames).not.toContain('vtd-datepicker-date-selected')
+ })
+})
diff --git a/tests/unit/header-selector-calendar-nav.spec.ts b/tests/unit/header-selector-calendar-nav.spec.ts
new file mode 100644
index 0000000..b6dc33c
--- /dev/null
+++ b/tests/unit/header-selector-calendar-nav.spec.ts
@@ -0,0 +1,84 @@
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import Header from '../../src/components/Header.vue'
+
+function createCalendarStub() {
+ return {
+ date: () => [],
+ month: 'Jan',
+ year: 2025,
+ years: () => [2024, 2025, 2026],
+ onPrevious: vi.fn(),
+ onNext: vi.fn(),
+ onPreviousYear: vi.fn(),
+ onNextYear: vi.fn(),
+ openMonth: vi.fn(),
+ setMonth: vi.fn(),
+ openYear: vi.fn(),
+ setYear: vi.fn(),
+ }
+}
+
+describe('Header calendar quick navigation in selector mode', () => {
+ it('keeps month prev/next arrows available in calendar view', async () => {
+ const calendar = createCalendarStub()
+ const wrapper = mount(Header, {
+ props: {
+ panel: {
+ calendar: true,
+ month: false,
+ year: false,
+ },
+ calendar,
+ selectorMode: true,
+ pickerViewMode: 'calendar',
+ panelName: 'previous',
+ },
+ })
+
+ await wrapper.get('[aria-label="Previous month"]').trigger('click')
+ await wrapper.get('[aria-label="Next month"]').trigger('click')
+
+ expect(calendar.onPrevious).toHaveBeenCalledTimes(1)
+ expect(calendar.onNext).toHaveBeenCalledTimes(1)
+ expect(calendar.onPreviousYear).not.toHaveBeenCalled()
+ expect(calendar.onNextYear).not.toHaveBeenCalled()
+ })
+
+ it('keeps side arrows visible and functional while selector wheels are open', async () => {
+ const calendar = createCalendarStub()
+ const wrapper = mount(Header, {
+ props: {
+ panel: {
+ calendar: true,
+ month: false,
+ year: false,
+ },
+ calendar,
+ selectorMode: true,
+ pickerViewMode: 'selector',
+ panelName: 'previous',
+ },
+ })
+
+ const previousButton = wrapper.get('[aria-label="Previous month"]')
+ const nextButton = wrapper.get('[aria-label="Next month"]')
+
+ expect((previousButton.element.closest('span') as HTMLElement | null)?.style.display).not.toBe('none')
+ expect((nextButton.element.closest('span') as HTMLElement | null)?.style.display).not.toBe('none')
+ expect(previousButton.classes()).toContain('opacity-65')
+ expect(nextButton.classes()).toContain('opacity-65')
+
+ await previousButton.trigger('click')
+ await nextButton.trigger('click')
+
+ expect(wrapper.emitted('step-month')).toEqual([
+ [{ panel: 'previous', delta: -1 }],
+ [{ panel: 'previous', delta: 1 }],
+ ])
+ expect(calendar.onPrevious).not.toHaveBeenCalled()
+ expect(calendar.onNext).not.toHaveBeenCalled()
+ expect(calendar.onPreviousYear).not.toHaveBeenCalled()
+ expect(calendar.onNextYear).not.toHaveBeenCalled()
+ })
+})
diff --git a/tests/unit/selector-focus-tint.spec.ts b/tests/unit/selector-focus-tint.spec.ts
new file mode 100644
index 0000000..bd2d775
--- /dev/null
+++ b/tests/unit/selector-focus-tint.spec.ts
@@ -0,0 +1,98 @@
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import Month from '../../src/components/Month.vue'
+import VueTailwindDatePicker from '../../src/VueTailwindDatePicker.vue'
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+async function mountSelectorPicker(selectorFocusTint: boolean) {
+ vi.useFakeTimers()
+ const wrapper = mount(VueTailwindDatePicker, {
+ attachTo: document.body,
+ props: {
+ noInput: true,
+ selectorMode: true,
+ selectorFocusTint,
+ useRange: false,
+ asSingle: true,
+ shortcuts: false,
+ autoApply: true,
+ modelValue: '2025-01-15 00:00:00',
+ },
+ })
+
+ vi.advanceTimersByTime(260)
+ await nextTick()
+ await wrapper.get('#vtd-header-previous-month').trigger('click')
+ await nextTick()
+
+ return wrapper
+}
+
+function getSelectorColumns(wrapper: ReturnType) {
+ const columns = wrapper.findAll('div.rounded-md.border.p-1.min-w-0')
+ expect(columns).toHaveLength(2)
+ return columns
+}
+
+describe('selectorFocusTint behavior', () => {
+ it('applies tint classes to the active selector column when enabled', async () => {
+ const wrapper = await mountSelectorPicker(true)
+
+ let [monthColumn, yearColumn] = getSelectorColumns(wrapper)
+ expect(monthColumn.classes()).toContain('bg-vtd-primary-50/40')
+ expect(monthColumn.classes()).toContain('ring-2')
+ expect(yearColumn.classes()).not.toContain('bg-vtd-primary-50/40')
+ expect(yearColumn.classes()).not.toContain('ring-2')
+
+ await wrapper.get('[aria-label="Year selector"]').trigger('focus')
+ await nextTick()
+
+ ;[monthColumn, yearColumn] = getSelectorColumns(wrapper)
+ expect(yearColumn.classes()).toContain('bg-vtd-primary-50/40')
+ expect(yearColumn.classes()).toContain('ring-2')
+ expect(monthColumn.classes()).not.toContain('bg-vtd-primary-50/40')
+
+ wrapper.unmount()
+ })
+
+ it('keeps neutral background when tint is disabled while preserving active border', async () => {
+ const wrapper = await mountSelectorPicker(false)
+
+ let [monthColumn, yearColumn] = getSelectorColumns(wrapper)
+ expect(monthColumn.classes()).toContain('border-vtd-primary-300')
+ expect(monthColumn.classes()).not.toContain('bg-vtd-primary-50/40')
+ expect(monthColumn.classes()).not.toContain('ring-2')
+
+ await wrapper.get('[aria-label="Year selector"]').trigger('focus')
+ await nextTick()
+
+ ;[monthColumn, yearColumn] = getSelectorColumns(wrapper)
+ expect(yearColumn.classes()).toContain('border-vtd-primary-300')
+ expect(yearColumn.classes()).not.toContain('bg-vtd-primary-50/40')
+ expect(yearColumn.classes()).not.toContain('ring-2')
+
+ wrapper.unmount()
+ })
+
+ it('keeps year focus styling when month scroll updates while year stays focused', async () => {
+ const wrapper = await mountSelectorPicker(true)
+
+ await wrapper.get('[aria-label="Year selector"]').trigger('focus')
+ await nextTick()
+
+ const monthComponent = wrapper.findComponent(Month)
+ monthComponent.vm.$emit('scrollMonth', { month: 2, year: 2025 })
+ await nextTick()
+
+ const [monthColumn, yearColumn] = getSelectorColumns(wrapper)
+ expect(yearColumn.classes()).toContain('bg-vtd-primary-50/40')
+ expect(yearColumn.classes()).toContain('ring-2')
+ expect(monthColumn.classes()).not.toContain('bg-vtd-primary-50/40')
+
+ wrapper.unmount()
+ })
+})
diff --git a/tests/unit/selector-wheel-keyboard.spec.ts b/tests/unit/selector-wheel-keyboard.spec.ts
new file mode 100644
index 0000000..91c27e2
--- /dev/null
+++ b/tests/unit/selector-wheel-keyboard.spec.ts
@@ -0,0 +1,363 @@
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { afterEach, describe, expect, it, vi } from 'vitest'
+import Month from '../../src/components/Month.vue'
+import Year from '../../src/components/Year.vue'
+import VueTailwindDatePicker from '../../src/VueTailwindDatePicker.vue'
+import type { LengthArray } from '../../src/types'
+
+const MONTHS: LengthArray = [
+ 'Jan',
+ 'Feb',
+ 'Mar',
+ 'Apr',
+ 'May',
+ 'Jun',
+ 'Jul',
+ 'Aug',
+ 'Sep',
+ 'Oct',
+ 'Nov',
+ 'Dec',
+] as const
+
+function dispatchKey(target: HTMLElement, options: { key: string; shiftKey?: boolean }) {
+ target.dispatchEvent(new KeyboardEvent('keydown', {
+ key: options.key,
+ bubbles: true,
+ cancelable: true,
+ shiftKey: !!options.shiftKey,
+ }))
+}
+
+afterEach(() => {
+ vi.useRealTimers()
+})
+
+async function mountSelectorPicker() {
+ vi.useFakeTimers()
+ const wrapper = mount(VueTailwindDatePicker, {
+ attachTo: document.body,
+ props: {
+ noInput: true,
+ selectorMode: true,
+ selectorFocusTint: true,
+ selectorYearScrollMode: 'boundary',
+ useRange: false,
+ asSingle: true,
+ shortcuts: false,
+ autoApply: true,
+ modelValue: '2025-01-15 00:00:00',
+ },
+ })
+
+ vi.advanceTimersByTime(260)
+ await nextTick()
+ return wrapper
+}
+
+async function mountRangeSelectorPicker() {
+ vi.useFakeTimers()
+ const wrapper = mount(VueTailwindDatePicker, {
+ attachTo: document.body,
+ props: {
+ noInput: true,
+ selectorMode: true,
+ selectorFocusTint: true,
+ selectorYearScrollMode: 'boundary',
+ useRange: true,
+ asSingle: false,
+ shortcuts: false,
+ autoApply: true,
+ modelValue: {
+ startDate: '2025-01-15 00:00:00',
+ endDate: '2025-04-20 00:00:00',
+ },
+ },
+ })
+
+ vi.advanceTimersByTime(260)
+ await nextTick()
+ return wrapper
+}
+
+describe('Selector wheel keyboard behavior', () => {
+ it('navigates month wheel and requests year focus with keyboard keys', async () => {
+ const wrapper = mount(Month, {
+ props: {
+ months: MONTHS,
+ selectorMode: true,
+ selectedMonth: 11,
+ selectedYear: 2024,
+ },
+ })
+
+ const selector = wrapper.get('[aria-label="Month selector"]')
+
+ await selector.trigger('keydown', { key: 'ArrowDown' })
+ await selector.trigger('keydown', { key: 'ArrowUp' })
+ await selector.trigger('keydown', { key: 'Tab' })
+ await selector.trigger('keydown', { key: 'ArrowRight' })
+
+ expect(wrapper.emitted('scrollMonth')).toEqual([
+ [{ month: 0, year: 2025 }],
+ [{ month: 10, year: 2024 }],
+ ])
+ expect(wrapper.emitted('requestFocusYear')).toHaveLength(2)
+ })
+
+
+ it('navigates year wheel and requests month focus with keyboard keys', async () => {
+ const years = Array.from({ length: 401 }, (_, index) => 1900 + index)
+ const wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 0,
+ },
+ })
+
+ const selector = wrapper.get('[aria-label="Year selector"]')
+
+ await selector.trigger('keydown', { key: 'ArrowDown' })
+ await selector.trigger('keydown', { key: 'PageDown' })
+ await selector.trigger('keydown', { key: 'Tab' })
+ await selector.trigger('keydown', { key: 'ArrowLeft' })
+
+ expect(wrapper.emitted('updateYear')).toEqual([
+ [2026],
+ [2035],
+ ])
+ expect(wrapper.emitted('requestFocusMonth')).toHaveLength(2)
+ })
+
+ it('supports configurable Home/End keyboard jumps for year wheel', async () => {
+ const years = Array.from({ length: 401 }, (_, index) => 1900 + index)
+ let wrapper: ReturnType
+ wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 0,
+ homeJump: 25,
+ endJump: 40,
+ onUpdateYear: (value: number) => {
+ wrapper.setProps({ selectedYear: value })
+ },
+ },
+ })
+
+ const selector = wrapper.get('[aria-label="Year selector"]')
+ await selector.trigger('keydown', { key: 'Home' })
+ await nextTick()
+ await selector.trigger('keydown', { key: 'End' })
+
+ expect(wrapper.emitted('updateYear')).toEqual([
+ [2000],
+ [2040],
+ ])
+ })
+
+ it('supports configurable PageUp/PageDown keyboard jumps for year wheel', async () => {
+ const years = Array.from({ length: 401 }, (_, index) => 1900 + index)
+ let wrapper: ReturnType
+ wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 0,
+ pageJump: 7,
+ pageShiftJump: 30,
+ onUpdateYear: (value: number) => {
+ wrapper.setProps({ selectedYear: value })
+ },
+ },
+ })
+
+ const selector = wrapper.get('[aria-label="Year selector"]')
+ await selector.trigger('keydown', { key: 'PageDown' })
+ await nextTick()
+ await selector.trigger('keydown', { key: 'PageUp', shiftKey: true })
+
+ expect(wrapper.emitted('updateYear')).toEqual([
+ [2032],
+ [2002],
+ ])
+ })
+
+ it('steps month wheel with click controls', async () => {
+ const wrapper = mount(Month, {
+ props: {
+ months: MONTHS,
+ selectorMode: true,
+ selectedMonth: 0,
+ selectedYear: 2025,
+ },
+ })
+
+ await wrapper.get('[aria-label="Select previous month"]').trigger('click')
+ await wrapper.get('[aria-label="Select next month"]').trigger('click')
+
+ expect(wrapper.emitted('scrollMonth')).toEqual([
+ [{ month: 11, year: 2024 }],
+ [{ month: 1, year: 2025 }],
+ ])
+ })
+
+ it('steps year wheel with click controls', async () => {
+ const years = Array.from({ length: 401 }, (_, index) => 1900 + index)
+ const wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 0,
+ },
+ })
+
+ await wrapper.get('[aria-label="Select previous year"]').trigger('click')
+ await wrapper.get('[aria-label="Select next year"]').trigger('click')
+
+ expect(wrapper.emitted('updateYear')).toEqual([
+ [2024],
+ [2026],
+ ])
+ })
+
+ it('does not re-center month wheel when only selected year changes', async () => {
+ const scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollTo')
+ const wrapper = mount(Month, {
+ props: {
+ months: MONTHS,
+ selectorMode: true,
+ selectedMonth: 2,
+ selectedYear: 2025,
+ },
+ })
+
+ await nextTick()
+ await nextTick()
+ scrollSpy.mockClear()
+
+ await wrapper.setProps({ selectedYear: 2026 })
+ await nextTick()
+ await nextTick()
+
+ expect(scrollSpy).not.toHaveBeenCalled()
+ scrollSpy.mockRestore()
+ })
+
+ it('keeps selected month row stable on year-only updates', async () => {
+ const wrapper = mount(Month, {
+ props: {
+ months: MONTHS,
+ selectorMode: true,
+ selectedMonth: 2,
+ selectedYear: 2025,
+ },
+ })
+
+ await nextTick()
+ await nextTick()
+
+ const selectedBefore = wrapper.get('[role="option"][aria-selected="true"]')
+ const selectedRowBefore = selectedBefore.element.closest('[data-month-index]') as HTMLElement | null
+ const selectedIndexBefore = Number(selectedRowBefore?.getAttribute('data-month-index'))
+
+ await wrapper.setProps({ selectedYear: 2026 })
+ await nextTick()
+
+ const selectedAfter = wrapper.get('[role="option"][aria-selected="true"]')
+ const selectedRowAfter = selectedAfter.element.closest('[data-month-index]') as HTMLElement | null
+ const selectedIndexAfter = Number(selectedRowAfter?.getAttribute('data-month-index'))
+ expect(selectedIndexAfter).toBe(selectedIndexBefore)
+ })
+
+ it('cycles selector focus with Tab and Shift+Tab', async () => {
+ const wrapper = await mountSelectorPicker()
+
+ await wrapper.get('#vtd-header-previous-month').trigger('click')
+ await nextTick()
+
+ const header = wrapper.get('#vtd-header-previous-month').element as HTMLElement
+ const monthSelector = wrapper.get('[aria-label="Month selector"]').element as HTMLElement
+ const yearSelector = wrapper.get('[aria-label="Year selector"]').element as HTMLElement
+
+ header.focus()
+ dispatchKey(header, { key: 'Tab' })
+ expect(document.activeElement).toBe(monthSelector)
+
+ dispatchKey(monthSelector, { key: 'Tab' })
+ expect(document.activeElement).toBe(yearSelector)
+
+ dispatchKey(yearSelector, { key: 'Tab' })
+ expect(document.activeElement).toBe(header)
+
+ dispatchKey(header, { key: 'Tab', shiftKey: true })
+ expect(document.activeElement).toBe(yearSelector)
+
+ wrapper.unmount()
+ })
+
+ it('updates month wheel selection when header month arrows are clicked in selector view', async () => {
+ const scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollTo')
+ const wrapper = await mountSelectorPicker()
+ await wrapper.get('#vtd-header-previous-month').trigger('click')
+ await nextTick()
+
+ const selectedBefore = wrapper.get('[aria-label="Month selector"] [role="option"][aria-selected="true"]')
+ const normalize = (value: string) => value.trim().slice(0, 3).toUpperCase()
+ const beforeMonth = normalize(selectedBefore.text())
+ const beforeIndex = MONTHS.findIndex(month => month.toUpperCase() === beforeMonth)
+ expect(beforeIndex).toBeGreaterThanOrEqual(0)
+
+ await wrapper.get('[aria-label="Previous month"]').trigger('click')
+ await nextTick()
+ await nextTick()
+
+ const calledWithSmoothBehavior = scrollSpy.mock.calls.some((call: any[]) => {
+ const firstArg = call[0] as any
+ return typeof firstArg === 'object' && firstArg !== null && firstArg.behavior === 'smooth'
+ })
+ expect(calledWithSmoothBehavior).toBe(true)
+
+ const selectedAfter = wrapper.get('[aria-label="Month selector"] [role="option"][aria-selected="true"]')
+ const afterMonth = normalize(selectedAfter.text())
+ const afterIndex = MONTHS.findIndex(month => month.toUpperCase() === afterMonth)
+ expect(afterIndex).toBe((beforeIndex + 11) % 12)
+
+ scrollSpy.mockRestore()
+ wrapper.unmount()
+ })
+
+ it('keeps both range panel selectors open when toggled from each header', async () => {
+ const wrapper = await mountRangeSelectorPicker()
+ const normalize = (value: string) => value.trim().slice(0, 3).toUpperCase()
+
+ await wrapper.get('#vtd-header-previous-month').trigger('click')
+ await nextTick()
+ expect(wrapper.find('[data-vtd-selector-panel="previous"] [aria-label="Month selector"]').exists()).toBe(true)
+ expect(wrapper.find('[data-vtd-selector-panel="next"] [aria-label="Month selector"]').exists()).toBe(false)
+ const previousBefore = normalize(
+ wrapper.get('[data-vtd-selector-panel="previous"] [aria-label="Month selector"] [role="option"][aria-selected="true"]').text(),
+ )
+
+ await wrapper.get('#vtd-header-next-month').trigger('click')
+ await nextTick()
+ expect(wrapper.find('[data-vtd-selector-panel="previous"] [aria-label="Month selector"]').exists()).toBe(true)
+ expect(wrapper.find('[data-vtd-selector-panel="next"] [aria-label="Month selector"]').exists()).toBe(true)
+ const previousAfter = normalize(
+ wrapper.get('[data-vtd-selector-panel="previous"] [aria-label="Month selector"] [role="option"][aria-selected="true"]').text(),
+ )
+ const nextSelected = normalize(
+ wrapper.get('[data-vtd-selector-panel="next"] [aria-label="Month selector"] [role="option"][aria-selected="true"]').text(),
+ )
+ expect(previousAfter).toBe(previousBefore)
+ expect(nextSelected).not.toBe(previousAfter)
+
+ wrapper.unmount()
+ })
+})
diff --git a/tests/unit/selector-year-scroll-mode.spec.ts b/tests/unit/selector-year-scroll-mode.spec.ts
new file mode 100644
index 0000000..ef39c1c
--- /dev/null
+++ b/tests/unit/selector-year-scroll-mode.spec.ts
@@ -0,0 +1,147 @@
+import { nextTick } from 'vue'
+import { mount } from '@vue/test-utils'
+import { describe, expect, it, vi } from 'vitest'
+import Year from '../../src/components/Year.vue'
+
+const years = Array.from({ length: 241 }, (_, index) => 1900 + index)
+
+async function flushTicks() {
+ await nextTick()
+ await nextTick()
+}
+
+describe('Selector year scroll mode', () => {
+ it('does not re-center the wheel on month drift in boundary mode', async () => {
+ const scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollTo')
+ const wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 5,
+ yearScrollMode: 'boundary',
+ },
+ })
+
+ await flushTicks()
+ scrollSpy.mockClear()
+
+ await wrapper.setProps({ selectedMonth: 6 })
+ await flushTicks()
+
+ expect(scrollSpy).not.toHaveBeenCalled()
+ })
+
+ it('applies smooth re-centering on month drift in fractional mode', async () => {
+ const scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollTo')
+ const wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 5,
+ yearScrollMode: 'fractional',
+ },
+ })
+
+ await flushTicks()
+ scrollSpy.mockClear()
+
+ await wrapper.setProps({ selectedMonth: 7 })
+ await flushTicks()
+
+ expect(scrollSpy).toHaveBeenCalled()
+ const calledWithSmoothBehavior = scrollSpy.mock.calls.some((call: any[]) => {
+ const firstArg = call[0] as any
+ return typeof firstArg === 'object' && firstArg !== null && firstArg.behavior === 'smooth'
+ })
+ expect(calledWithSmoothBehavior).toBe(true)
+ })
+
+ it('uses custom wheel cell height for click hit-testing', async () => {
+ const rect = {
+ x: 0,
+ y: 0,
+ width: 240,
+ height: 256,
+ top: 0,
+ right: 240,
+ bottom: 256,
+ left: 0,
+ toJSON: () => ({}),
+ } as DOMRect
+ const rectSpy = vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue(rect)
+
+ async function clickYearAtOffset(customHeight?: string) {
+ const wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 5,
+ yearScrollMode: 'fractional',
+ },
+ })
+ await flushTicks()
+
+ const selector = wrapper.get('[aria-label="Year selector"]')
+ const selectorElement = selector.element as HTMLDivElement
+ if (customHeight) {
+ selectorElement.style.setProperty('--vtd-selector-wheel-cell-height', customHeight)
+ await selector.trigger('focus')
+ await flushTicks()
+ }
+
+ await selector.trigger('click', { clientY: 220 })
+ await flushTicks()
+ const nextYear = Number(wrapper.emitted('updateYear')?.at(-1)?.[0])
+ wrapper.unmount()
+ return nextYear
+ }
+
+ const defaultCellHeightYear = await clickYearAtOffset()
+ const tallCellHeightYear = await clickYearAtOffset('72px')
+ rectSpy.mockRestore()
+
+ expect(tallCellHeightYear).toBeLessThan(defaultCellHeightYear)
+ })
+
+ it('keeps smooth motion on repeated year step-button clicks', async () => {
+ const scrollSpy = vi.spyOn(HTMLElement.prototype, 'scrollTo')
+ let wrapper: ReturnType
+ wrapper = mount(Year, {
+ props: {
+ years,
+ selectorMode: true,
+ selectedYear: 2025,
+ selectedMonth: 5,
+ yearScrollMode: 'boundary',
+ onUpdateYear: (value: number) => {
+ wrapper.setProps({ selectedYear: value })
+ },
+ },
+ })
+ await flushTicks()
+
+ const nextButton = wrapper.get('[aria-label="Select next year"]')
+
+ scrollSpy.mockClear()
+ await nextButton.trigger('click')
+ await flushTicks()
+ const firstBehaviors = scrollSpy.mock.calls
+ .map((call: any[]) => (call[0] as ScrollToOptions | undefined)?.behavior)
+ .filter((behavior): behavior is ScrollBehavior => typeof behavior === 'string')
+
+ scrollSpy.mockClear()
+ await nextButton.trigger('click')
+ await flushTicks()
+ const secondBehaviors = scrollSpy.mock.calls
+ .map((call: any[]) => (call[0] as ScrollToOptions | undefined)?.behavior)
+ .filter((behavior): behavior is ScrollBehavior => typeof behavior === 'string')
+
+ expect(firstBehaviors).toContain('smooth')
+ expect(firstBehaviors).not.toContain('auto')
+ expect(secondBehaviors).toContain('smooth')
+ expect(secondBehaviors).not.toContain('auto')
+ })
+})
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..6cd31d0
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,18 @@
+import path from 'node:path'
+import { defineConfig } from 'vitest/config'
+import vue from '@vitejs/plugin-vue'
+
+export default defineConfig({
+ plugins: [vue()],
+ resolve: {
+ alias: {
+ '~': path.resolve(__dirname, 'src'),
+ },
+ },
+ test: {
+ environment: 'jsdom',
+ setupFiles: ['./tests/setup.ts'],
+ include: ['tests/unit/**/*.spec.ts'],
+ clearMocks: true,
+ },
+})