feat(Calendar): add month/year picker modes and view switching#5981
feat(Calendar): add month/year picker modes and view switching#5981
Conversation
d181f50 to
28da0df
Compare
|
@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 |
|
Thanks for the PR! I do like the API with the 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? |
|
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? |
|
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 |
|
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 🤔 |
|
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. |
|
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 :) |
4d64993 to
ae0edf7
Compare
commit: |
|
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 |
83cbfce to
dc8123d
Compare
dc8123d to
ec30460
Compare
db211f6 to
ff305e7
Compare
There was a problem hiding this comment.
♻️ Duplicate comments (2)
src/runtime/components/Calendar.vue (2)
218-219:⚠️ Potential issue | 🟠 MajorSync formatter locale when
codechanges 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 -C2Also 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 | 🟠 MajorAvoid duplicate
update:placeholderemission in day mode.
useForwardPropsEmits(..., emits)already forwards emitted updates; keeping explicit@update:placeholder="updatePlaceholder"onDayCalendar.Rootcan 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 -C2Also 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
📒 Files selected for processing (1)
src/runtime/components/Calendar.vue
There was a problem hiding this comment.
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
📒 Files selected for processing (5)
docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vuedocs/content/docs/2.components/calendar.mdsrc/runtime/components/Calendar.vuesrc/theme/calendar.tstest/components/Calendar.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- docs/content/docs/2.components/calendar.md
|
@onmax I think the resolved issue is wrong! |
|
fixed |
5581f82 to
cd84b16
Compare
cd84b16 to
6959644
Compare
There was a problem hiding this comment.
🧹 Nitpick comments (1)
src/runtime/components/Calendar.vue (1)
208-212: Consider usingsetLocale()instead of recreating the formatter.Reka UI's
useDateFormatterprovides asetLocale()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
⛔ Files ignored due to path filters (2)
test/components/__snapshots__/Calendar-vue.spec.ts.snapis excluded by!**/*.snaptest/components/__snapshots__/Calendar.spec.ts.snapis excluded by!**/*.snap
📒 Files selected for processing (6)
docs/app/components/content/examples/calendar/CalendarMonthPickerExample.vuedocs/app/components/content/examples/calendar/CalendarYearPickerExample.vuedocs/content/docs/2.components/calendar.mdsrc/runtime/components/Calendar.vuesrc/theme/calendar.tstest/components/Calendar.spec.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- docs/app/components/content/examples/calendar/CalendarYearPickerExample.vue
There was a problem hiding this comment.
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
📒 Files selected for processing (1)
src/runtime/components/Calendar.vue
howwohmm
left a comment
There was a problem hiding this comment.
impressive feature — the architecture of splitting day/month/year into distinct root components with a dynamic picker computed is clean.
two things I noticed:
-
stale label reactivity in monthPicker/yearPicker computed. the computed properties extract
.valuefrom reactive label refs (prevYearLabel.value,nextYearLabel.value) inside the computed body. the string is captured at evaluation time, but the computed's primary dependency isprops.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.valuein the template, or restructure so the computed reads from the refs on each access. -
watch on
[props.type, props.view]— unsafe destructure on first run. the callback destructures[previousType]from the old value, which isundefinedon the initial invocation. thetype !== previousTypecheck works by coincidence (props.type defaults to'date'which isn'tundefined), but an explicitif (!previousType) returnguard 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.
howwohmm
left a comment
There was a problem hiding this comment.
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.
|
@onmax In the latest version of reka-ui, I've added the missing inputs, so you can switch your imports of |
5940ea9 to
f3c9965
Compare
|
I have updated htis pr:
|
| 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'] |
There was a problem hiding this comment.
@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?
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
@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!
f3c9965 to
c55e60e
Compare

🔗 Linked issue
Resolves #5842, resolves #3652
Related #3094
❓ Type of change
📚 Description
typeproptypepropChanges:
typeprop (date|month|year) for standalone pickersview/defaultViewprops for view state controltype="date"monthGrid,monthCell,yearGrid,yearCellmonth-cell,year-cellfor custom rendering📸 Screenshots
type="month")type="year")Cap.2026-02-04.at.14.31.40.mp4
📝 Checklist