From 9bee7540867d8fe35456328b3f4f8fa79dde9d9c Mon Sep 17 00:00:00 2001 From: Iosif Chatzimichail Date: Sat, 30 May 2026 17:27:54 +0300 Subject: [PATCH 1/5] Precision setting --- CHANGELOG.md | 6 + README.md | 47 ++++ demo/index.html | 101 ++++++++ eslint.config.js | 8 + src/plugin/calendar-component.ts | 155 +++++++++++- tests/unit/precision-month.test.ts | 391 +++++++++++++++++++++++++++++ 6 files changed, 701 insertions(+), 7 deletions(-) create mode 100644 tests/unit/precision-month.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 3f32ff9..d986c36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **`precision: 'month'` — a forward-looking month picker.** Opens directly on the months grid (with the year prev/next arrows), positioned by the usual `value` > `initialMonth` > today precedence and clamped into `[minDate, maxDate]`. Clicking a month commits the first day of that month as the single selection, emits `calendar:change` (`detail.dates[0]` = first-of-month), formats the input via `format` (e.g. `'MMMM YYYY'` → `"August 2026"`), and closes the popup — no day grid, no year step. The bound input becomes read-only (selection-only), `format` defaults to `'MM/YYYY'`, and a stored first-of-month `value` restores both the displayed string and the highlighted month. Designed to compose with `mode: 'single'`; combining with `range`/`multiple` or `wizard` warns (precision takes precedence over the wizard). + ## [1.0.1] ### Fixed diff --git a/README.md b/README.md index b20805e..3dc281e 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,52 @@ To use a custom ref name: Wizard modes: `true` (or `'full'`) for Year → Month → Day, `'year-month'` for Year → Month, `'month-day'` for Month → Day. +### Month Picker (departure-month) + +Set `precision: 'month'` to turn the calendar into a forward-looking **month picker**. It opens directly on the months grid (with the year prev/next arrows), and clicking a month commits the **first day of that month** as the selection — there is no day grid and no separate year step. + +```html +
+ +
+``` + +Behavior: + +- **Opens on the months grid**, positioned with the usual precedence: `value` > `initialMonth` > today. The opening month is clamped into `[minDate, maxDate]`, so the picker never opens on an out-of-range year. +- **`minDate`/`maxDate` are hard limits.** The year arrows are disabled at the first/last in-range year, and months outside the range are disabled. With `minDate: '2026-06-01'` and `maxDate: '2027-12-31'`, only **2026** and **2027** are reachable and months before June 2026 are disabled. +- **Clicking a month commits the 1st of that month**, emits `calendar:change` (with `detail.dates[0]` = first-of-month, e.g. `'2026-08-01'`), formats the input via `format` (e.g. `'MMMM YYYY'` → `"August 2026"`), and closes the popup. +- The bound input is made **read-only** (selection-only) — a `'MMMM YYYY'` value can't be typed back in. +- Set `name` to submit the value in a plain form — a hidden `` carries the first-of-month ISO string (e.g. `2026-08-01`), no event wiring needed. +- `format` defaults to `'MM/YYYY'` when omitted. Use a month-only format such as `'MM/YYYY'` or `'MMMM YYYY'`; a day-based format logs a warning. +- Designed for `mode: 'single'`. Combining it with `range`/`multiple`, or with `wizard`, logs a warning (`precision: 'month'` takes precedence over the wizard). + +**Restoring a selection across pages.** Pass the stored first-of-month back as `value` (or the month as `initialMonth`): + +```html + +
+ +
+``` + +`value` both selects and displays the month; `initialMonth` (`'2026-08'`) only positions the view without selecting. + ### Form Submission ```html @@ -157,6 +203,7 @@ All options are passed via `x-data="calendar({ ... })"`. | Option | Type | Default | Description | |--------|------|---------|-------------| | `mode` | `'single' \| 'multiple' \| 'range'` | `'single'` | Selection mode | +| `precision` | `'day' \| 'month'` | `'day'` | Selection granularity. `'month'` is a month picker (see [Month Picker](#month-picker-departure-month)) | | `display` | `'inline' \| 'popup'` | `'inline'` | Inline calendar or popup with input | | `format` | `string` | `'DD/MM/YYYY'` | Date format (tokens: `DD`, `MM`, `YYYY`, `D`, `M`, `YY`, `MMM`, `MMMM`) | | `months` | `number` | `1` | Months to display (1=single, 2=dual side-by-side, 3+=scrollable) | diff --git a/demo/index.html b/demo/index.html index c5ff44e..9b7a0bd 100644 --- a/demo/index.html +++ b/demo/index.html @@ -396,6 +396,7 @@

Alpine Calendar

Constraints Rules Wizard + Month Picker Metadata Scrollable Theming @@ -895,6 +896,106 @@

Wizard Mode

+ +
+

Month Picker

+

Set precision: 'month' for a forward-looking month picker. It opens on the months grid, and clicking a month commits the first of that month — perfect for a "departure month" booking selector.

+ +
+ +
+
+
Month Picker
+
Opens on the months grid; year arrows navigate. No day step.
+
+
+
+
+
+ View Code +
<div x-data="calendar({
+  mode: 'single',
+  precision: 'month',
+  format: 'MMMM YYYY'})"></div>
+
+
+ +
+
+
Booking Window (min / max)
+
minDate/maxDate are hard limits: only 2026–2027 are reachable, months before June 2026 are disabled.
+
+
+
+
+
+ View Code +
<div x-data="calendar({
+  mode: 'single',
+  precision: 'month',
+  format: 'MMMM YYYY',
+  minDate: '2026-06-01',
+  maxDate: '2027-12-31'})"></div>
+
+
+ +
+
+
Popup + Restore from Value
+
Bound to a read-only input. A stored first-of-month value restores both the "August 2026" label and the highlighted month.
+
+
+
+ +
+
+
+ View Code +
<div x-data="calendar({
+  mode: 'single',
+  precision: 'month',
+  format: 'MMMM YYYY',
+  display: 'popup',
+  minDate: '2026-06-01',
+  maxDate: '2027-12-31',
+  value: '2026-08-01'})">
+  <input x-ref="rc-input" type="text" class="rc-input"
+         placeholder="Departure month...">
+</div>
+
+
+ +
+
+
Form Submission (no JS)
+
With name set, a hidden input submits the first-of-month ISO value (e.g. 2026-08-01) — no event wiring needed.
+
+
+
+
+ +
+
+
+ View Code +
<form method="post">
+  <div x-data="calendar({
+    mode: 'single',
+    precision: 'month',
+    format: 'MMMM YYYY',
+    name: 'departure_month'
+  })"></div>
+  <button type="submit">Submit</button>
+</form>
+<!-- POSTs departure_month=2026-08-01 (first-of-month ISO) -->
+
+
+ +
+
+ diff --git a/eslint.config.js b/eslint.config.js index 2aff681..8b9f1b6 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -14,5 +14,13 @@ export default tseslint.config( '@typescript-eslint/consistent-type-imports': 'error', }, }, + { + // Test files routinely assert known-present values; non-null assertions are + // idiomatic here and used throughout the suite. + files: ['tests/**/*.ts'], + rules: { + '@typescript-eslint/no-non-null-assertion': 'off', + }, + }, { ignores: ['dist/', 'demo/', 'coverage/'] }, ) diff --git a/src/plugin/calendar-component.ts b/src/plugin/calendar-component.ts index 5312237..9d4e06e 100644 --- a/src/plugin/calendar-component.ts +++ b/src/plugin/calendar-component.ts @@ -76,6 +76,17 @@ export type { RangePreset } from '../core/presets' export interface CalendarConfig { /** Selection mode. Default: 'single'. */ mode?: 'single' | 'multiple' | 'range' + /** + * Selection precision. Default: 'day'. + * + * `'month'` turns the calendar into a forward-looking month picker: it opens on the + * months grid, clicking a month commits the first day of that month as the selection + * (emitting `calendar:change` with `dates[0]` = first-of-month), and the day grid is + * never shown. The bound input becomes read-only (selection-only). Designed to compose + * with `mode: 'single'`. The opening month follows the usual precedence + * (`value` > `initialMonth` > today) and is clamped into `[minDate, maxDate]`. + */ + precision?: 'day' | 'month' /** Display mode. Default: 'inline'. */ display?: 'inline' | 'popup' /** Date format string (e.g. 'DD/MM/YYYY'). Default: 'DD/MM/YYYY'. */ @@ -404,6 +415,32 @@ function validateConfig(config: CalendarConfig): void { if (config.initialMonth && !parseInitialMonth(config.initialMonth)) { warn(`invalid initialMonth: "${config.initialMonth}" (expected 'YYYY-MM' or 'YYYY-MM-DD')`) } + + // precision must be 'day' or 'month' + if ( + config.precision !== undefined && + config.precision !== 'day' && + config.precision !== 'month' + ) { + warn(`invalid precision: "${config.precision}" (expected 'day' or 'month')`) + } + + // precision: 'month' compatibility + if (config.precision === 'month') { + if (config.mode && config.mode !== 'single') { + warn( + `precision: 'month' is designed for single selection; mode "${config.mode}" may not work as expected`, + ) + } + if (config.wizard) { + warn(`precision: 'month' overrides wizard mode; the wizard UI is ignored`) + } + if (config.format && /D/.test(config.format)) { + warn( + `precision: 'month' with a day-based format "${config.format}"; use a month-only format like 'MM/YYYY' or 'MMMM YYYY'`, + ) + } + } } /** @@ -552,8 +589,10 @@ export function createCalendarData( // --- Parse config with defaults --- const mode = config.mode ?? 'single' + const precision = config.precision ?? 'day' const display = config.display ?? 'inline' - const format = config.format ?? 'DD/MM/YYYY' + // Month-precision pickers default to a month-only display format. + const format = config.format ?? (precision === 'month' ? 'MM/YYYY' : 'DD/MM/YYYY') const firstDay = config.firstDay ?? 1 let timezone = config.timezone if (timezone) { @@ -684,11 +723,30 @@ export function createCalendarData( initialDates.length > 0 ? initialDates[0] : (initialMonthDate ?? today) ) as CalendarDate - // Wizard: center year picker around ~30 years ago (full & year-month modes) + // For precision: 'month', clamp the opening view into [minDate, maxDate] so the picker + // never opens on a fully out-of-range year (e.g. when today precedes minDate). + const minViewBound = config.minDate ? CalendarDate.fromISO(config.minDate) : null + const maxViewBound = config.maxDate ? CalendarDate.fromISO(config.maxDate) : null + const clampViewToRange = (d: CalendarDate): CalendarDate => { + if (minViewBound && d.isBefore(minViewBound.startOfMonth())) { + return new CalendarDate(minViewBound.year, minViewBound.month, 1) + } + if (maxViewBound && d.isAfter(maxViewBound.endOfMonth())) { + return new CalendarDate(maxViewBound.year, maxViewBound.month, 1) + } + return d + } + + // Initial view position: + // - precision month: forward-looking; honors value > initialMonth > today, clamped to range. + // - wizard full/year-month: center year picker around ~30 years ago (birth-date default). + // - otherwise: the resolved defaultViewDate. const viewDate = - wizardMode === 'full' || wizardMode === 'year-month' - ? new CalendarDate(today.year - 30, today.month, today.day) - : defaultViewDate + precision === 'month' + ? clampViewToRange(defaultViewDate) + : wizardMode === 'full' || wizardMode === 'year-month' + ? new CalendarDate(today.year - 30, today.month, today.day) + : defaultViewDate // --- Compute initial inputValue --- function computeFormattedValue(sel: Selection): string { @@ -728,7 +786,10 @@ export function createCalendarData( // --- Reactive state --- month: viewDate.month, year: viewDate.year, - view: (wizard ? wizardStartView : 'days') as 'days' | 'months' | 'years', + view: (precision === 'month' ? 'months' : wizard ? wizardStartView : 'days') as + | 'days' + | 'months' + | 'years', isOpen: display === 'inline', grid: [] as MonthGrid[], monthGrid: [] as MonthCell[][], @@ -1342,7 +1403,16 @@ export function createCalendarData( * Compute CSS class object for a month cell in the month picker view. */ monthClasses(cell: MonthCell): Record { - const selected = this.month === cell.month && this.view === 'days' + // Register the selection counter so Alpine re-evaluates on commit. + void this._selectionRev + let selected: boolean + if (precision === 'month') { + // Highlight the committed month regardless of view (drives restore from value). + const sel = this._selection.toArray()[0] as CalendarDate | undefined + selected = !!sel && sel.year === cell.year && sel.month === cell.month + } else { + selected = this.month === cell.month && this.view === 'days' + } return { 'rc-month': true, 'rc-month--current': cell.isCurrentMonth, @@ -1502,6 +1572,12 @@ export function createCalendarData( // Set initial value el.value = this.inputValue + // Month-precision pickers are selection-only: a 'MMMM YYYY' value can't be + // re-parsed from typed text, so make the input read-only (click to open). + if (precision === 'month') { + el.readOnly = true + } + // Attach mask if enabled (auto-disabled for month-name formats) if (useMask && !formatHasMonthName(format)) { this._detachInput = attachMask(el, format) @@ -1541,6 +1617,9 @@ export function createCalendarData( const prevDetach = this._detachInput this._detachInput = () => { prevDetach?.() + if (precision === 'month') { + el.readOnly = false + } el.removeEventListener('input', syncHandler) el.removeEventListener('focus', focusHandler) el.removeEventListener('blur', blurHandler) @@ -1584,6 +1663,13 @@ export function createCalendarData( * Parses the typed value, updates selection if valid, and reformats the input. */ handleBlur() { + // Month-precision pickers are read-only/selection-only; never re-parse typed text, + // just re-assert the canonical formatted value. + if (precision === 'month') { + this._syncInputFromSelection() + return + } + const value = this._inputEl ? this._inputEl.value : this.inputValue // Empty input → clear selection @@ -2268,6 +2354,13 @@ export function createCalendarData( selectMonth(targetMonth: number) { if (this._isMonthDisabled(this.year, targetMonth)) return + + // Precision month: the month is the unit of selection — commit it and finish. + if (precision === 'month') { + this._commitMonth(this.year, targetMonth) + return + } + this.month = targetMonth this._wizardMonth = targetMonth @@ -2286,6 +2379,45 @@ export function createCalendarData( } }, + /** + * Commit the first day of the given month as the single selection. + * + * Used by `precision: 'month'`. The month is the unit of selection, so the + * month-level disabled check (not the day-level one) is authoritative: the + * representative day — the 1st — is committed even when it precedes `minDate` + * (e.g. minDate is mid-month). Emits `calendar:change` with `dates[0]` = + * first-of-month, syncs the formatted input, and closes the popup. + */ + _commitMonth(year: number, month: number) { + if (this._isMonthDisabled(year, month)) return + const first = new CalendarDate(year, month, 1) + + // beforeSelect hook — a month commit is always a 'select' action. + if (beforeSelectCb) { + const result = beforeSelectCb(first, { + mode, + selectedDates: this._selection.toArray(), + action: 'select', + }) + if (result === false) return + } + + this.year = year + this.month = month + this._selection.clear() + this._selection.toggle(first) + this._selectionRev++ + this._emitChange() + this._syncInputFromSelection() + + this._announce(first.format({ month: 'long', year: 'numeric' }, locale) + ' selected') + + // Selecting a month is terminal — close the popup like a completed single selection. + if (closeOnSelect && display === 'popup' && this.isOpen) { + this.close() + } + }, + selectYear(targetYear: number) { if (this._isYearDisabled(targetYear)) return this.year = targetYear @@ -2341,6 +2473,15 @@ export function createCalendarData( this.year = this._today.year - 30 this._rebuildYearGrid() } + } else if (precision === 'month') { + // Always reopen on the months grid, repositioned onto the committed selection. + this.view = 'months' + const sel = this._selection.toArray()[0] as CalendarDate | undefined + if (sel) { + this.year = sel.year + this.month = sel.month + } + this._rebuildMonthGrid() } this.isOpen = true diff --git a/tests/unit/precision-month.test.ts b/tests/unit/precision-month.test.ts new file mode 100644 index 0000000..dacec39 --- /dev/null +++ b/tests/unit/precision-month.test.ts @@ -0,0 +1,391 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { createCalendarData } from '../../src/plugin/calendar-component' +import type { CalendarConfig } from '../../src/plugin/calendar-component' +import { withAlpineMocks } from '../helpers' + +/** + * Mount a calendar through the full init() path. Pass `refs` to bind an input + * (e.g. to exercise the read-only behavior). + */ +function createComponent(config: CalendarConfig = {}, refs?: Record) { + const c = createCalendarData(config) + const mocks = withAlpineMocks(c, refs ? { refs } : undefined) + c.init() + mocks.flushNextTick() + return { c, ...mocks } +} + +/** Return the detail of the most recent `calendar:change` dispatch, or null. */ +function lastChangeDetail(dispatchSpy: ReturnType) { + const call = [...dispatchSpy.mock.calls].reverse().find((c) => c[0] === 'calendar:change') + return call ? (call[1] as { value: string; dates: string[]; formatted: string }) : null +} + +// --------------------------------------------------------------------------- +// Initial view +// --------------------------------------------------------------------------- + +describe('precision: month — initial view', () => { + it('opens on the months grid (not the day grid, not a year step)', () => { + const { c } = createComponent({ precision: 'month' }) + expect(c.view).toBe('months') + }) + + it('a day-precision calendar still opens on the day grid', () => { + const { c } = createComponent({}) + expect(c.view).toBe('days') + }) + + it('forward-positions on today when no value/initialMonth (never today - 30)', () => { + const today = new Date() + const { c } = createComponent({ precision: 'month' }) + expect(c.year).toBe(today.getFullYear()) + expect(c.month).toBe(today.getMonth() + 1) + }) + + it('positions from initialMonth', () => { + const { c } = createComponent({ precision: 'month', initialMonth: '2030-08' }) + expect(c.year).toBe(2030) + expect(c.month).toBe(8) + }) + + it('value takes precedence over initialMonth for positioning', () => { + const { c } = createComponent({ + precision: 'month', + value: '2030-03-01', + initialMonth: '2030-08', + }) + expect(c.year).toBe(2030) + expect(c.month).toBe(3) + }) + + it('clamps the opening view UP to minDate when today precedes it', () => { + // today (real run date) is well before 2030, so the clamp must engage. + const { c } = createComponent({ + precision: 'month', + minDate: '2030-06-01', + maxDate: '2031-12-31', + }) + expect(c.year).toBe(2030) + expect(c.month).toBe(6) + }) + + it('clamps the opening view DOWN to maxDate when today is after it', () => { + const { c } = createComponent({ + precision: 'month', + minDate: '2019-01-01', + maxDate: '2020-12-31', + }) + expect(c.year).toBe(2020) + expect(c.month).toBe(12) + }) + + it('does not clamp a value that is already inside the range', () => { + const { c } = createComponent({ + precision: 'month', + value: '2026-08-01', + minDate: '2026-06-01', + maxDate: '2027-12-31', + }) + expect(c.year).toBe(2026) + expect(c.month).toBe(8) + }) +}) + +// --------------------------------------------------------------------------- +// Committing a month +// --------------------------------------------------------------------------- + +describe('precision: month — committing a month', () => { + it('clicking a month commits the first day of that month as the single selection', () => { + const { c } = createComponent({ precision: 'month', initialMonth: '2026-01' }) + c.selectMonth(8) + expect(c.selectedDates).toHaveLength(1) + expect(c.selectedDates[0]!.toISO()).toBe('2026-08-01') + }) + + it('emits calendar:change with dates[0] = first-of-month', () => { + const { c, dispatchSpy } = createComponent({ precision: 'month', initialMonth: '2026-01' }) + c.selectMonth(8) + const detail = lastChangeDetail(dispatchSpy) + expect(detail).not.toBeNull() + expect(detail!.dates[0]).toBe('2026-08-01') + }) + + it('formats the input via `format` (MMMM YYYY -> "August 2026")', () => { + const { c } = createComponent({ + precision: 'month', + format: 'MMMM YYYY', + initialMonth: '2026-01', + }) + c.selectMonth(8) + expect(c.inputValue).toBe('August 2026') + }) + + it('stays on the months view (no drill into the day grid)', () => { + const { c } = createComponent({ precision: 'month', initialMonth: '2026-01' }) + c.selectMonth(8) + expect(c.view).toBe('months') + }) + + it('closes the popup after a month is selected', () => { + const input = document.createElement('input') + const { c } = createComponent( + { precision: 'month', display: 'popup', format: 'MMMM YYYY', initialMonth: '2026-01' }, + { 'rc-input': input }, + ) + c.open() + expect(c.isOpen).toBe(true) + c.selectMonth(8) + expect(c.isOpen).toBe(false) + }) + + it('commits day 1 even when minDate falls mid-month (month is the unit)', () => { + const { c, dispatchSpy } = createComponent({ + precision: 'month', + minDate: '2026-06-15', + maxDate: '2027-12-31', + }) + // June 2026 is selectable (part of it is in range), so day 1 must commit + // despite preceding minDate. + c.selectMonth(6) + expect(c.selectedDates[0]!.toISO()).toBe('2026-06-01') + expect(lastChangeDetail(dispatchSpy)!.dates[0]).toBe('2026-06-01') + }) + + it('clicking a disabled (out-of-range) month is a no-op', () => { + const { c, dispatchSpy } = createComponent({ + precision: 'month', + value: '2026-08-01', + minDate: '2026-06-01', + maxDate: '2027-12-31', + }) + dispatchSpy.mockClear() + c.selectMonth(5) // May 2026 — before minDate + expect(c.selectedDates[0]!.toISO()).toBe('2026-08-01') // unchanged + expect(dispatchSpy.mock.calls.find((cc) => cc[0] === 'calendar:change')).toBeUndefined() + }) + + it('re-clicking the already-selected month keeps it selected (no toggle-off)', () => { + const { c } = createComponent({ precision: 'month', value: '2026-08-01' }) + c.selectMonth(8) + expect(c.selectedDates).toHaveLength(1) + expect(c.selectedDates[0]!.toISO()).toBe('2026-08-01') + }) + + it('honors a beforeSelect veto', () => { + const { c } = createComponent({ + precision: 'month', + initialMonth: '2026-01', + beforeSelect: () => false, + }) + c.selectMonth(8) + expect(c.selectedDates).toHaveLength(0) + }) + + it('defaults to a month-only format (MM/YYYY) when none is given', () => { + const { c } = createComponent({ precision: 'month', initialMonth: '2026-01' }) + c.selectMonth(8) + expect(c.inputValue).toBe('08/2026') + }) + + it('exposes the first-of-month ISO as the hidden form input value', () => { + const { c } = createComponent({ + precision: 'month', + name: 'departure_month', + initialMonth: '2026-01', + }) + c.selectMonth(8) + expect(c.hiddenInputValues).toEqual(['2026-08-01']) + }) +}) + +// --------------------------------------------------------------------------- +// Restore from value +// --------------------------------------------------------------------------- + +describe('precision: month — restore from value', () => { + it('displays the formatted month in the input', () => { + const { c } = createComponent({ + precision: 'month', + format: 'MMMM YYYY', + value: '2026-08-01', + }) + expect(c.inputValue).toBe('August 2026') + }) + + it('marks the stored month selected in the months grid — even though view is "months"', () => { + const { c } = createComponent({ + precision: 'month', + format: 'MMMM YYYY', + value: '2026-08-01', + }) + expect(c.view).toBe('months') + const august = c.monthGrid.flat().find((cell) => cell.month === 8)! + const july = c.monthGrid.flat().find((cell) => cell.month === 7)! + expect(c.monthClasses(august)['rc-month--selected']).toBe(true) + expect(c.monthClasses(july)['rc-month--selected']).toBe(false) + }) + + it('opens on the stored year', () => { + const { c } = createComponent({ + precision: 'month', + value: '2026-08-01', + minDate: '2026-06-01', + maxDate: '2027-12-31', + }) + expect(c.year).toBe(2026) + }) + + it('reopening the popup repositions onto the committed selection', () => { + const input = document.createElement('input') + const { c } = createComponent( + { + precision: 'month', + display: 'popup', + format: 'MMMM YYYY', + minDate: '2026-01-01', + maxDate: '2027-12-31', + }, + { 'rc-input': input }, + ) + c.open() + c.selectMonth(8) // commit Aug 2026, closes popup + // Navigate away while closed, then reopen — should snap back to the selection. + c.year = 2027 + c.open() + expect(c.view).toBe('months') + expect(c.year).toBe(2026) + expect(c.month).toBe(8) + }) + + it('initialMonth positions the view but does NOT select a month', () => { + const { c } = createComponent({ precision: 'month', initialMonth: '2026-08' }) + expect(c.year).toBe(2026) + expect(c.month).toBe(8) + expect(c.selectedDates).toHaveLength(0) + const august = c.monthGrid.flat().find((cell) => cell.month === 8)! + expect(c.monthClasses(august)['rc-month--selected']).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Year navigation clamping (hard min/max limits) +// --------------------------------------------------------------------------- + +describe('precision: month — year clamping to [minYear, maxYear]', () => { + const RANGE: CalendarConfig = { + precision: 'month', + minDate: '2026-06-01', + maxDate: '2027-12-31', + } + + it('only years 2026 and 2027 are reachable', () => { + const { c } = createComponent({ ...RANGE, value: '2026-08-01' }) + expect(c._isYearDisabled(2025)).toBe(true) + expect(c._isYearDisabled(2026)).toBe(false) + expect(c._isYearDisabled(2027)).toBe(false) + expect(c._isYearDisabled(2028)).toBe(true) + }) + + it('disables the prev arrow at the min year (months view)', () => { + const { c } = createComponent({ ...RANGE, value: '2026-08-01' }) + expect(c.view).toBe('months') + expect(c.year).toBe(2026) + expect(c.canGoPrev).toBe(false) + expect(c.canGoNext).toBe(true) + }) + + it('disables the next arrow at the max year (months view)', () => { + const { c } = createComponent({ ...RANGE, value: '2027-03-01' }) + expect(c.year).toBe(2027) + expect(c.canGoNext).toBe(false) + expect(c.canGoPrev).toBe(true) + }) + + it('disables months before minDate in the opening year', () => { + const { c } = createComponent({ ...RANGE, value: '2026-08-01' }) + const may = c.monthGrid.flat().find((cell) => cell.month === 5)! + const june = c.monthGrid.flat().find((cell) => cell.month === 6)! + expect(may.isDisabled).toBe(true) + expect(june.isDisabled).toBe(false) + }) +}) + +// --------------------------------------------------------------------------- +// Read-only input +// --------------------------------------------------------------------------- + +describe('precision: month — read-only input', () => { + it('makes the bound input read-only', () => { + const input = document.createElement('input') + createComponent( + { precision: 'month', display: 'popup', format: 'MMMM YYYY' }, + { + 'rc-input': input, + }, + ) + expect(input.readOnly).toBe(true) + }) + + it('leaves a day-precision input editable', () => { + const input = document.createElement('input') + createComponent({ display: 'popup' }, { 'rc-input': input }) + expect(input.readOnly).toBe(false) + }) + + it('handleBlur never re-parses or clears the selection', () => { + const input = document.createElement('input') + const { c } = createComponent( + { precision: 'month', display: 'popup', format: 'MMMM YYYY', initialMonth: '2026-01' }, + { 'rc-input': input }, + ) + c.open() + c.selectMonth(8) + // Simulate a stray blur (input is read-only, but the handler must still be safe). + input.value = 'garbage' + c.handleBlur() + expect(c.selectedDates[0]!.toISO()).toBe('2026-08-01') + expect(c.inputValue).toBe('August 2026') + }) +}) + +// --------------------------------------------------------------------------- +// Config validation +// --------------------------------------------------------------------------- + +describe('precision: month — config validation', () => { + let warnSpy: ReturnType + + beforeEach(() => { + warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + }) + afterEach(() => { + warnSpy.mockRestore() + }) + + it('warns when combined with a non-single mode', () => { + createCalendarData({ precision: 'month', mode: 'range' }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('precision')) + }) + + it('warns when combined with wizard mode', () => { + createCalendarData({ precision: 'month', wizard: true }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('wizard')) + }) + + it('warns when an explicit format contains a day token', () => { + createCalendarData({ precision: 'month', format: 'DD/MM/YYYY' }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('day-based format')) + }) + + it('warns on an invalid precision value', () => { + createCalendarData({ precision: 'week' as unknown as 'month' }) + expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining('invalid precision')) + }) + + it('does not warn for a valid precision: month single picker', () => { + createCalendarData({ precision: 'month', mode: 'single', format: 'MMMM YYYY' }) + expect(warnSpy).not.toHaveBeenCalled() + }) +}) From 4653d5d32c6ae25cac251f989e918f3f53b5a104 Mon Sep 17 00:00:00 2001 From: Iosif Chatzimichail Date: Sat, 30 May 2026 21:09:37 +0300 Subject: [PATCH 2/5] Wizard precedence --- src/plugin/calendar-component.ts | 48 ++++++- src/plugin/template.ts | 14 +- tests/unit/precision-month.test.ts | 218 ++++++++++++++++++++++++++++- 3 files changed, 271 insertions(+), 9 deletions(-) diff --git a/src/plugin/calendar-component.ts b/src/plugin/calendar-component.ts index 9d4e06e..ca365ab 100644 --- a/src/plugin/calendar-component.ts +++ b/src/plugin/calendar-component.ts @@ -602,7 +602,11 @@ export function createCalendarData( timezone = undefined } } - const wizardConfig = config.wizard ?? false + // precision: 'month' takes precedence over the wizard (validateConfig warns this). + // Neutralize the wizard at the source so every downstream derivation — wizard chrome + // in the template, the initial view, and the open() reopen path — ignores it + // consistently, rather than only some of them honoring the override. + const wizardConfig = precision === 'month' ? false : (config.wizard ?? false) const rawMonthCount = config.months ?? 1 // Force months: 1 when wizard + scrollable const desktopMonthCount = wizardConfig && rawMonthCount >= 3 ? 1 : rawMonthCount @@ -683,10 +687,35 @@ export function createCalendarData( const isInitDisabled = (d: CalendarDate) => constraints.isDisabledDate(d) || initialGetDateMeta(d)?.availability === 'unavailable' + // Month-level disabled check for precision: 'month' restore. Mirrors the runtime + // _isMonthDisabled (the deep check in _wrapWithDeepChecks): a month is disabled when + // the shallow month constraint rejects it OR every day in it is effectively disabled. + const isInitMonthDisabled = (year: number, month: number): boolean => { + if (constraints.isMonthDisabled(year, month)) return true + const count = daysInMonth(year, month) + for (let day = 1; day <= count; day++) { + if (!isInitDisabled(new CalendarDate(year, month, day))) return false + } + return true + } + if (config.value) { if (mode === 'single') { const d = parseDate(config.value, format, locale) ?? CalendarDate.fromISO(config.value) - if (d && !isInitDisabled(d)) selection.toggle(d) + if (d) { + if (precision === 'month') { + // The month is the unit of selection: accept the value when its month is + // selectable and normalize to the first of the month — matching the value + // _commitMonth() emits. The day-level isInitDisabled check would wrongly drop + // a first-of-month value that precedes a mid-month minDate (e.g. value + // 2026-06-01 with minDate 2026-06-15), even though the picker allows June. + if (!isInitMonthDisabled(d.year, d.month)) { + selection.toggle(new CalendarDate(d.year, d.month, 1)) + } + } else if (!isInitDisabled(d)) { + selection.toggle(d) + } + } } else if (mode === 'range') { const range = parseDateRange(config.value, format, locale) if (range) { @@ -1051,6 +1080,7 @@ export function createCalendarData( needsScrollableView, isDualChrome, isWizard: this.wizardMode !== 'none', + precisionMonth: precision === 'month', hasName: !!this.inputName, showWeekNumbers: this.showWeekNumbers, hasPresets: this.presets.length > 0, @@ -1574,6 +1604,9 @@ export function createCalendarData( // Month-precision pickers are selection-only: a 'MMMM YYYY' value can't be // re-parsed from typed text, so make the input read-only (click to open). + // Capture the original state so detach restores it — the input may have been + // read-only for the consumer's own reasons, which we must not clobber. + const hadReadOnly = el.readOnly if (precision === 'month') { el.readOnly = true } @@ -1618,7 +1651,7 @@ export function createCalendarData( this._detachInput = () => { prevDetach?.() if (precision === 'month') { - el.readOnly = false + el.readOnly = hadReadOnly } el.removeEventListener('input', syncHandler) el.removeEventListener('focus', focusHandler) @@ -2349,6 +2382,9 @@ export function createCalendarData( // --- View switching --- setView(newView: 'days' | 'months' | 'years') { + // Month precision is a single month-grid step: the day grid and year step are + // never part of its flow, so refuse to switch away from the months view. + if (precision === 'month' && newView !== 'months') return this.view = newView }, @@ -2542,8 +2578,10 @@ export function createCalendarData( this.wizardBack() return } - // If in month or year picker, return to days view - if (this.view === 'months' || this.view === 'years') { + // If in month or year picker, return to days view. Month precision has no + // day view to fall back to — the months grid is the whole picker — so let + // Escape fall through to the popup-close behavior instead of exposing days. + if (precision !== 'month' && (this.view === 'months' || this.view === 'years')) { this.view = 'days' return } diff --git a/src/plugin/template.ts b/src/plugin/template.ts index 048fee1..f721366 100644 --- a/src/plugin/template.ts +++ b/src/plugin/template.ts @@ -11,6 +11,8 @@ export interface TemplateOptions { /** Include dual-month chrome (nav-arrow visibility classes, rc-months--dual binding). */ isDualChrome: boolean isWizard: boolean + /** Month-precision picker: the months grid is the only view; no year-step affordance. */ + precisionMonth?: boolean hasName: boolean showWeekNumbers: boolean hasPresets: boolean @@ -82,12 +84,17 @@ function yearPickerView(): string { ` } -function monthPickerView(): string { +function monthPickerView(precisionMonth: boolean): string { + // Month-precision pickers have no year step to drill into, so the year label is a + // static span rather than a button that would switch to the (unreachable) year grid. + const yearLabelEl = precisionMonth + ? `` + : `` return `