Skip to content

feat(Calendar): add month/year picker modes and view switching#5981

Open
onmax wants to merge 5 commits intonuxt:v4from
onmax:feat/calendar-month-year-picker
Open

feat(Calendar): add month/year picker modes and view switching#5981
onmax wants to merge 5 commits intonuxt:v4from
onmax:feat/calendar-month-year-picker

Conversation

@onmax
Copy link
Copy Markdown
Contributor

@onmax onmax commented Feb 2, 2026

🔗 Linked issue

Resolves #5842, resolves #3652
Related #3094

❓ Type of change

  • 📖 Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • 👌 Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

📚 Description

  1. Add supports for calendar month picker modes via type prop
  2. Add support for year picker modes via type prop
  3. Interactive view switching (day -> month -> year). Maybe too opinionated?

Changes:

  • Add type prop (date | month | year) for standalone pickers
  • Add view / defaultView props for view state control
  • Clickable heading to switch views (day -> month -> year) when type="date"
  • New theme slots: monthGrid, monthCell, yearGrid, yearCell
  • New slots: month-cell, year-cell for custom rendering

📸 Screenshots

Feature Screenshot
Month Picker (type="month") image
Year Picker (type="year") image
View Switching (heading click) video below
Cap.2026-02-04.at.14.31.40.mp4

📝 Checklist

  • I have linked an issue or discussion.
  • I have updated the documentation accordingly.

@github-actions github-actions Bot added the v4 #4488 label Feb 2, 2026
@onmax onmax force-pushed the feat/calendar-month-year-picker branch from d181f50 to 28da0df Compare February 2, 2026 15:41
@onmax
Copy link
Copy Markdown
Contributor Author

onmax commented Feb 2, 2026

@benjamincanac what do you think about this PR? Do you like the API?

There are some styles I would like to improve and nail down. But I think is mostly ready :)

Let me know your thoughts! Thank you!

Once we decide to move forward I will ammend PR fixing CI and fixing UI issues

Copy link
Copy Markdown
Member

Thanks for the PR! I do like the API with the type prop approach, it's consistent with other components.

However, it adds lots of code since there are no primitives for this in Reka UI. @J-Michalek what do you think about this? Are you aware of such thing being added in Reka UI any time soon?

@J-Michalek
Copy link
Copy Markdown
Contributor

Well there is an issue open for 8 months unovue/reka-ui#1933 and the activity in the repo is mild at best, but I think we should rely on RekaUI to provide these inputs...

Perhaps the author of this PR would be interested in implementing in RekaUI?

@caiotarifa
Copy link
Copy Markdown
Contributor

caiotarifa commented Feb 3, 2026

I’m the author of the feature request in Reka UI (unovue/reka-ui#1933), and it has already gathered meaningful community interest (19+ reactions).

As mentioned above, this PR adds a fair amount of code mainly because Reka UI doesn’t provide primitives for month/year picking yet.

@onmax, if you’re open to it, migrating this PR to add MonthPicker and YearPicker primitives in Reka UI would be an excellent win for the ecosystem (as @J-Michalek suggested).

@onmax
Copy link
Copy Markdown
Contributor Author

onmax commented Feb 3, 2026

Thanks for the feedback. I will prepare a PR for Reka UI.

@benjamincanac, should I keep the behaviour shown in the video? I am not 100% this is the best ux 🤔

@benjamincanac
Copy link
Copy Markdown
Member

I think I like it yes, it avoids having to implement popover and lets us render a Calendar only for months at the same time as selecting a month for a normal calendar.

@onmax
Copy link
Copy Markdown
Contributor Author

onmax commented Feb 3, 2026

Ok, i won't bother you again 😬,

once reka ui is released and this pr is ready I will mark this pr ready and i will ping you.

Feel free to post any feedback though. Most of the code is ready. I am just testing it throughly :)

@onmax onmax force-pushed the feat/calendar-month-year-picker branch 2 times, most recently from 4d64993 to ae0edf7 Compare February 3, 2026 17:54
@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented Feb 3, 2026

npm i https://pkg.pr.new/@nuxt/ui@5981

commit: f3c9965

@onmax
Copy link
Copy Markdown
Contributor Author

onmax commented Feb 4, 2026

For reference: much better approach imho

image

@sewalsh
Copy link
Copy Markdown

sewalsh commented Feb 4, 2026

Great to see work on this! I've long missed it since version 2.

Just my 2c. I think a 3x4 grid looks neater as was used in v2: https://ui2.nuxt.com/components/date-picker#datepicker

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (2)
src/runtime/components/Calendar.vue (2)

218-219: ⚠️ Potential issue | 🟠 Major

Sync formatter locale when code changes at runtime.

useDateFormatter(code.value) is created once, but month/year labels continue using that formatter later. If locale switches dynamically, labels can stay stale unless the formatter locale is updated.

💡 Proposed fix
 const formatter = useDateFormatter(code.value)
+watch(() => code.value, (newCode) => {
+  formatter.setLocale(newCode)
+})
#!/bin/bash
# Verify formatter initialization and whether locale sync exists
rg -n "useDateFormatter\\(|setLocale\\(|watch\\(\\(\\) => code\\.value" src/runtime/components/Calendar.vue -C2

Also applies to: 341-352

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Calendar.vue` around lines 218 - 219, The date
formatter created with useDateFormatter(code.value) is only initialized once so
when code.value (locale) changes at runtime the formatter stays stale; update
the implementation around the formatter variable (the const formatter =
useDateFormatter(code.value) in Calendar.vue) to react to code changes—either by
watching code.value and calling formatter.setLocale(newLocale) if the formatter
exposes setLocale, or by recreating the formatter inside a watch on code.value
(e.g., replace the single initialization with a watch that reassigns formatter
using useDateFormatter(code.value)); ensure the same approach is applied to the
other affected block around lines referenced (341-352).

220-221: ⚠️ Potential issue | 🟠 Major

Avoid duplicate update:placeholder emission in day mode.

useForwardPropsEmits(..., emits) already forwards emitted updates; keeping explicit @update:placeholder="updatePlaceholder" on DayCalendar.Root can emit twice for one user action.

💡 Proposed fix
-const calendarRootProps = useForwardPropsEmits(reactiveOmit(props, 'type', 'view', 'defaultView', 'range', 'modelValue', 'defaultValue', 'placeholder', 'color', 'variant', 'size', 'monthControls', 'yearControls', 'class', 'ui'), emits)
+const _calendarRootProps = useForwardPropsEmits(
+  reactiveOmit(props, 'type', 'view', 'defaultView', 'range', 'modelValue', 'defaultValue', 'placeholder', 'color', 'variant', 'size', 'monthControls', 'yearControls', 'class', 'ui'),
+  emits
+)
+const calendarRootProps = computed(() => ({
+  ..._calendarRootProps.value,
+  'onUpdate:placeholder': updatePlaceholder
+}))
-    `@update`:placeholder="updatePlaceholder"
#!/bin/bash
# Verify possible duplicate placeholder update wiring in day view
rg -n "useForwardPropsEmits|@update:placeholder|updatePlaceholder\\(|'update:placeholder'" src/runtime/components/Calendar.vue -C2

Also applies to: 431-432

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Calendar.vue` around lines 220 - 221, Duplicate
placeholder updates occur because useForwardPropsEmits(calendarRootProps, emits)
already forwards emits and DayCalendar.Root still has an explicit
`@update`:placeholder="updatePlaceholder"; remove the explicit `@update`:placeholder
binding from DayCalendar.Root (and the similar binding around lines ~431-432) so
only useForwardPropsEmits handles the emit, keeping calendarRootProps and the
emits object intact and ensuring updatePlaceholder helper (if still referenced
elsewhere) is not hooked twice.
🧹 Nitpick comments (1)
src/runtime/components/Calendar.vue (1)

576-874: Consider extracting shared month/year panel structure.

Month/year panel markup is duplicated across navigation and standalone branches, which increases maintenance cost and drift risk. A shared internal subcomponent/composable for header+grid rendering would simplify future changes.

Also applies to: 689-965

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Calendar.vue` around lines 576 - 874, The month/year
panel header+grid markup is duplicated between the MonthPicker and YearPicker
branches (see MonthPickerHeaderComp/MonthPickerGridComp/MonthPickerCellComp and
YearPickerHeaderComp/YearPickerGridComp/YearPickerCellComp usage plus helpers
like pickerPanelClass, pickerHeaderStyle, pickerHeaderHeadingStyle,
getPickerColumnAlignment, setView, setMonth, setYear, yearControls); extract
that shared structure into a single internal component or composable (e.g.,
CalendarPanel) that accepts props for the heading slot/value, grid rows, cell
renderers, navigation controls, aria labels and emits necessary events
(update:model-value, update:placeholder) so MonthPicker/YearPicker/standalone
Month branches simply render <CalendarPanel :grid="..." :date="..." ...> and
forward slots/props, keeping behavior (buttons, click handlers, classes, and
styles) identical.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@src/runtime/components/Calendar.vue`:
- Around line 218-219: The date formatter created with
useDateFormatter(code.value) is only initialized once so when code.value
(locale) changes at runtime the formatter stays stale; update the implementation
around the formatter variable (the const formatter =
useDateFormatter(code.value) in Calendar.vue) to react to code changes—either by
watching code.value and calling formatter.setLocale(newLocale) if the formatter
exposes setLocale, or by recreating the formatter inside a watch on code.value
(e.g., replace the single initialization with a watch that reassigns formatter
using useDateFormatter(code.value)); ensure the same approach is applied to the
other affected block around lines referenced (341-352).
- Around line 220-221: Duplicate placeholder updates occur because
useForwardPropsEmits(calendarRootProps, emits) already forwards emits and
DayCalendar.Root still has an explicit `@update`:placeholder="updatePlaceholder";
remove the explicit `@update`:placeholder binding from DayCalendar.Root (and the
similar binding around lines ~431-432) so only useForwardPropsEmits handles the
emit, keeping calendarRootProps and the emits object intact and ensuring
updatePlaceholder helper (if still referenced elsewhere) is not hooked twice.

---

Nitpick comments:
In `@src/runtime/components/Calendar.vue`:
- Around line 576-874: The month/year panel header+grid markup is duplicated
between the MonthPicker and YearPicker branches (see
MonthPickerHeaderComp/MonthPickerGridComp/MonthPickerCellComp and
YearPickerHeaderComp/YearPickerGridComp/YearPickerCellComp usage plus helpers
like pickerPanelClass, pickerHeaderStyle, pickerHeaderHeadingStyle,
getPickerColumnAlignment, setView, setMonth, setYear, yearControls); extract
that shared structure into a single internal component or composable (e.g.,
CalendarPanel) that accepts props for the heading slot/value, grid rows, cell
renderers, navigation controls, aria labels and emits necessary events
(update:model-value, update:placeholder) so MonthPicker/YearPicker/standalone
Month branches simply render <CalendarPanel :grid="..." :date="..." ...> and
forward slots/props, keeping behavior (buttons, click handlers, classes, and
styles) identical.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ab468c0c-8f73-4a93-ad60-2443c92496d4

📥 Commits

Reviewing files that changed from the base of the PR and between 7fc753c and 2183508.

📒 Files selected for processing (1)
  • src/runtime/components/Calendar.vue

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Calendar.vue`:
- Around line 267-281: The current effectivePlaceholder always falls back to
today(getLocalTimeZone()) which causes MonthPicker/YearPicker to open on today
even when modelValue/defaultValue are provided; change effectivePlaceholder to
derive from value when props.placeholder is not provided by computing:
localPlaceholder.value ?? props.modelValue ?? props.defaultValue ??
today(getLocalTimeZone()); keep dayViewPlaceholder behavior as-is (it already
respects explicit placeholder/localPlaceholder), and ensure the bindings to
MonthPicker and YearPicker use this updated effectivePlaceholder (references:
localPlaceholder, effectivePlaceholder, dayViewPlaceholder, props.placeholder,
props.modelValue, props.defaultValue, MonthPicker, YearPicker).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 112db6dc-50df-4a41-aef4-73caa0ce38fc

📥 Commits

Reviewing files that changed from the base of the PR and between 2183508 and 5581f82.

📒 Files selected for processing (5)
  • docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vue
  • docs/content/docs/2.components/calendar.md
  • src/runtime/components/Calendar.vue
  • src/theme/calendar.ts
  • test/components/Calendar.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/content/docs/2.components/calendar.md

Comment thread src/runtime/components/Calendar.vue Outdated
@benjamincanac
Copy link
Copy Markdown
Member

@onmax I think the resolved issue is wrong!

@onmax
Copy link
Copy Markdown
Contributor Author

onmax commented Mar 4, 2026

fixed

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (1)
src/runtime/components/Calendar.vue (1)

208-212: Consider using setLocale() instead of recreating the formatter.

Reka UI's useDateFormatter provides a setLocale() method specifically for updating the locale. This is more efficient than recreating the formatter instance on each change:

💡 Suggested refactor
-const formatter = shallowRef(useDateFormatter(code.value))
-
-watch(() => code.value, (value) => {
-  formatter.value = useDateFormatter(value)
-})
+const formatter = useDateFormatter(code.value)
+
+watch(() => code.value, (value) => {
+  formatter.setLocale(value)
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/runtime/components/Calendar.vue` around lines 208 - 212, The current code
recreates the date formatter on each locale change by assigning formatter.value
= useDateFormatter(value); instead use the formatter's built-in updater: keep
formatter as shallowRef(useDateFormatter(code.value)) and in the watch callback
call formatter.value.setLocale(value) (ensure setLocale exists on the returned
object) instead of replacing the whole formatter instance to improve efficiency
and preserve any internal state or subscriptions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@src/runtime/components/Calendar.vue`:
- Around line 208-212: The current code recreates the date formatter on each
locale change by assigning formatter.value = useDateFormatter(value); instead
use the formatter's built-in updater: keep formatter as
shallowRef(useDateFormatter(code.value)) and in the watch callback call
formatter.value.setLocale(value) (ensure setLocale exists on the returned
object) instead of replacing the whole formatter instance to improve efficiency
and preserve any internal state or subscriptions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 6c23c1df-28da-43ee-8187-b64d3c7ae764

📥 Commits

Reviewing files that changed from the base of the PR and between 5581f82 and 6959644.

⛔ Files ignored due to path filters (2)
  • test/components/__snapshots__/Calendar-vue.spec.ts.snap is excluded by !**/*.snap
  • test/components/__snapshots__/Calendar.spec.ts.snap is excluded by !**/*.snap
📒 Files selected for processing (6)
  • docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vue
  • docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue
  • docs/content/docs/2.components/calendar.md
  • src/runtime/components/Calendar.vue
  • src/theme/calendar.ts
  • test/components/Calendar.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/runtime/components/Calendar.vue`:
- Around line 619-635: The inline branch that renders <component
:is="picker.root"> is dropping root-level props by passing a hand-picked set
(pickerValueProps, placeholder, locale, dir, minValue, maxValue, disabled,
readonly, class) instead of reusing the same root prop assembly used by the
day-calendar path; fix this by using the same calendarRootProps construction
(the same prop spread used for day mode) when rendering picker.root and then
explicitly override only the value/placeholder wiring and event handlers (keep
picker.onUpdate and onPickerPlaceholderUpdate) so documented root props like as
and other root-level options are preserved across type !== 'date' modes.
- Around line 241-245: The watcher currently re-applies props.defaultView after
mount, turning it into live state; change the logic so defaultView only seeds
internalView once and is not watched thereafter. Concretely: stop watching
props.defaultView (remove it from the watcher), initialize internalView from
defaultView on mount (or only when internalView is undefined), and update the
watcher to only react to props.type and props.view so that internalView is
updated when the component is controlled (props.view changes) or when props.type
changes (in which case call getDefaultView(type, defaultView) to compute a new
seed if internalView is undefined). Ensure you reference and update the existing
watcher and the initialization for internalView and use getDefaultView(type as
CalendarType, defaultView as CalendarView | undefined) where needed.
- Around line 364-379: Remove the no-op expressions causing the linter error by
deleting the stray `code.value` lines in the functions `formatMonthLabel` and
`formatYearLabel`; leave the rest of each function intact so they continue to
call `formatter.value.custom(date.toDate(getLocalTimeZone()), {...})` and fall
back to `String(date.month)` / appropriate fallback — this removes the
unused-expression warnings from `@typescript-eslint/no-unused-expressions`
without changing reactivity (the locale sync is already handled by the watcher).

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: ba04acfe-5bd5-4986-bc24-479bb70b5631

📥 Commits

Reviewing files that changed from the base of the PR and between 6959644 and abb210e.

📒 Files selected for processing (1)
  • src/runtime/components/Calendar.vue

Comment thread src/runtime/components/Calendar.vue Outdated
Comment thread src/runtime/components/Calendar.vue Outdated
Comment thread src/runtime/components/Calendar.vue
Copy link
Copy Markdown
Contributor

@howwohmm howwohmm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

impressive feature — the architecture of splitting day/month/year into distinct root components with a dynamic picker computed is clean.

two things I noticed:

  1. stale label reactivity in monthPicker/yearPicker computed. the computed properties extract .value from reactive label refs (prevYearLabel.value, nextYearLabel.value) inside the computed body. the string is captured at evaluation time, but the computed's primary dependency is props.range, not the label refs themselves. if the locale changes at runtime, the labels won't update because the computed won't re-evaluate. either pass the refs directly and resolve .value in the template, or restructure so the computed reads from the refs on each access.

  2. watch on [props.type, props.view] — unsafe destructure on first run. the callback destructures [previousType] from the old value, which is undefined on the initial invocation. the type !== previousType check works by coincidence (props.type defaults to 'date' which isn't undefined), but an explicit if (!previousType) return guard would be more robust.

also worth noting: the heading slot's value field now changes semantics depending on the view (e.g. "2025" in year view vs "January 2025" in day view). existing consumers using #heading="{ value }" won't break, but the string they get will differ — might be worth a migration note in the docs.

Copy link
Copy Markdown
Contributor

@howwohmm howwohmm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

correction on my earlier comment about stale label reactivity — I was wrong on that point.

Vue's reactivity system tracks all .value accesses inside a computed() getter, so prevYearLabel.value and nextYearLabel.value ARE registered as dependencies of the monthPicker/yearPicker computed. the labels will correctly update on locale change. apologies for the false alarm there.

the watch destructure point (undefined old value on first run) and the heading slot semantics observation still stand, but those are minor.

@mateusznarowski
Copy link
Copy Markdown
Contributor

@onmax In the latest version of reka-ui, I've added the missing inputs, so you can switch your imports of MonthPicker and YearPicker to the namespaced versions. unovue/reka-ui#2571

@onmax onmax force-pushed the feat/calendar-month-year-picker branch from 5940ea9 to f3c9965 Compare April 7, 2026 14:02
@onmax
Copy link
Copy Markdown
Contributor Author

onmax commented Apr 7, 2026

I have updated htis pr:

  1. I rebased it onto current v4
  2. resolved the calendar conflicts
  3. switched the month/year picker imports to the new namespaced Reka API
  4. refreshed the affected snapshots

Comment thread src/theme/calendar.ts Outdated
yearGrid: 'w-full select-none space-y-1 focus:outline-none',
yearGridRow: 'grid grid-cols-4 gap-1',
yearCell: 'relative text-center',
yearCellTrigger: ['relative flex w-full items-center justify-center rounded-md whitespace-nowrap tabular-nums focus-visible:ring-2 focus:outline-none data-disabled:text-muted', options.theme.transitions && 'transition']
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@onmax Why duplicate all these slots? They should be identical for day, month and year no? Otherwise we could use a type variant that targets cell, cellTrigger and cellWeek instead of duplicating everything, what do you think?

Copy link
Copy Markdown
Contributor Author

@onmax onmax Apr 8, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree, I would group month and year

day is not fully identical to month/year. Day view has week headers/numbers and different trigger states, so I would not force all three into literally the same classes. But month and year are close enough. What do you think?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@benjamincanac I have pushed the changes I sent in my previous message, and also cleaned up a bit, as well as rebased again.

Let me know if there is something else to fix and get this pr merged!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

v4 #4488

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add <UMonthPicker> and <UYearPicker> components (requires RekaUI Calendar view modes) Calendar: improve month and year select

7 participants