Skip to content

feat(Calendar): pick a month or year from a list - #1317

Merged
tenphi merged 4 commits into
mainfrom
andrew/cub-3903-improve-calendar-component-in-ui-kit
Aug 14, 2026
Merged

feat(Calendar): pick a month or year from a list#1317
tenphi merged 4 commits into
mainfrom
andrew/cub-3903-improve-calendar-component-in-ui-kit

Conversation

@tenphi

@tenphi tenphi commented Aug 14, 2026

Copy link
Copy Markdown
Member

Closes CUB-3903.

Pick a month or a year from a list

The calendar header's title is now the navigation. The month and the year are separate buttons: pressing the month opens a month list, pressing the year opens a year list, and picking a year drops you into that year's months. Reaching a date two years out is two clicks instead of twenty-four.

MonthPicker and QuarterPicker get the same treatment — the year in their header opens a year list, so 2026 → 2019 no longer means seven clicks on the arrow.

The month and year panels only navigate. They move the focused date and never select a value, so a range selection in progress survives a jump to another month.

Opt out with hasMonthYearNavigation={false}.

Shared internals

Calendar, RangeCalendar and PeriodCalendar had three copies of the same header, the same shell element and two divergent copies of the cell styles. They now share:

  • CalendarHeader — title segments + prev/next, one size of nav button (RangeCalendar used small where Calendar used xsmall)
  • PeriodGrid — one accessible grid for months, quarters and years
  • styled.tsx — one set of cell states

CalendarPanel holds the day/month/year switching for both Calendar and RangeCalendar.

Review findings, fixed

  • Calendar never attached its ref. domRef was created, wrapped in useImperativeHandle, and then dropped — createDOMRef(domRef) always resolved to null. RangeCalendar did attach it.
  • PeriodPicker spread the field's labelProps onto its value text, duplicating the label's id (wrapWithField applies the same props to the real <label>).
  • PeriodPicker ignored isReadOnly — clicking the field opened the popover anyway.
  • The trigger never announced the selected value. There are no date segments to read, so the button is now aria-describedby the value text.
  • The period panel was a role="grid" with no rows and aria-pressed on its cells. It is now a real grid (rows, gridcell, aria-selected) with a roving tab stop.
  • Keyboard support in the period panels was partial: arrows clamped at the page edge and there was no paging. Arrows now roll over into the neighbouring year or decade, PageUp/PageDown page, Home/End jump to the first/last selectable period, disabled cells are stepped over, and Escape steps back one panel instead of closing the popover.
  • Switching panels dropped focus to <body> (the button you pressed unmounts). Focus now follows into the new grid.
  • Periods were disabled by comparing against a snapped bound, so maxValue={2026-09-15} disabled all of September. A period is now disabled only when all of it falls outside the range.
  • hidden on out-of-range day cells did nothing (display: grid overrides [hidden]), so they rendered anyway. Replaced with an explicit outside modifier — same pixels, honest markup.
  • gap: 1x on the calendar shell did nothing (display: block). The shell is now a max-content grid, which also stops a standalone calendar from stretching its cells across its container.
  • Nothing marked today. Day and period cells now carry a current modifier.
  • formatValue was typed (date, picker) but called with (date, picker, locale).
  • PeriodPicker's placeholders were hardcoded English; those and the new calendar labels are translated in all twelve locales.

Not fixed (pre-existing, out of scope)

  • Range selections still paint every day in the range identically; distinct start/end caps would read better. Noted in the docs' Suggested Improvements.

Docs, stories, tests

Calendar had no stories, docs or tests. It now has all three (Other/Calendar), including play-driven stories for the month and year panels so Chromatic captures them. PeriodPicker.docs.mdx gained accessibility and style-props sections.

Calendar is still not exported from the package root: export * from '@internationalized/date' already exports a Calendar interface, and shadowing it would be a breaking change for consumers.

Verification

pnpm test (90 files, 1841 tests), pnpm oxlint, tsc --noEmit and pnpm audit-defaults are clean. Every panel was driven by hand in Storybook — light and dark, mouse and keyboard, across DatePicker, DateRangePicker, DateRangeSeparatedPicker and the four period pickers.

Two existing DatePicker assertions changed: the heading's text is now two buttons rather than one string, so they assert on the buttons.

🤖 Generated with Claude Code


Update: Escape now closes the popover

The earlier note claiming this was pre-existing Dialog behaviour was wrong on the cause, and the bug turned out to be fixable — it is fixed here.

Popover called useOverlay from inside PopoverWrapper. That component is rendered by Overlay, which clones its child with an isOpen derived from the transition (phase === 'entered'), not from the trigger. React Aria only pushes an overlay onto its visible-overlay stack while isOpen, and onHide dismisses only the topmost entry — so for the first frames after opening, the popover was not in the stack and Escape did nothing. In a background tab, where requestAnimationFrame never advances the phase past enter, it never worked at all, which is why it looked permanent.

useOverlay now runs in Popover against the trigger's open state, exactly where Modal and Tray already call it. This affects every DialogTrigger type="popover", not just the date fields.

One more fix fell out of it: when Escape steps back from the calendar's month or year panel, the grid the user was in unmounts. Without claiming focus for the day cell, focus fell out of the popover entirely and the next Escape was lost. CalendarPanel now calls state.setFocused(true) on the way back.

Covered by three new tests in DialogTrigger.test.tsx (dismiss during enter, dismiss after entered, and staying open when a child handles Escape) plus two in DatePicker.test.tsx for the step-back behaviour. Reverting Popover.tsx alone fails the "right after opening" test and passes the "once settled" one, which pins the diagnosis.

The calendar header's month and year are now buttons that open a month
list and a year list, so reaching a distant date takes two clicks instead
of paging with the arrows. `MonthPicker` and `QuarterPicker` gained the
same year list behind the year in their header.

The panels are shared: `Calendar`, `RangeCalendar` and `PeriodCalendar`
now build on one header, one cell style set and one accessible
`PeriodGrid` with full keyboard support.

Also reviewed the surrounding implementation: `Calendar` never attached
its ref to the DOM, `PeriodPicker` duplicated the field's label props
onto its value text and ignored `isReadOnly`, and the period panels were
a `role="grid"` with no rows.

Closes CUB-3903

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cursor

cursor Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Bugbot is paused — on-demand spend limit reached

Bugbot uses usage-based billing for this team and has hit its on-demand spend limit.

A team admin can raise the spend limit in the Cursor dashboard, or wait for the next billing cycle to continue.

@changeset-bot

changeset-bot Bot commented Aug 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: f4b5992

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Aug 14, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
cube-ui-kit Ready Ready Preview Aug 14, 2026 4:18pm

Request Review

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-694a764.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Passed?
All 487.09 KB (+0.51% 🔺) Yes 🎉
Tree shaking (just a Button) 119.44 KB (-0.03% 🔽👏) Yes 🎉

Click here if you want to find out what is changed in this build

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

`Popover` called `useOverlay` from inside `PopoverWrapper`, which `Overlay`
renders with an `isOpen` derived from the transition — false until the
enter animation reaches `entered`. React Aria only registers an overlay in
its visible-overlay stack while `isOpen`, and only dismisses the topmost
registered one, so `Escape` was a no-op for the first frames after opening
(and forever in a background tab, where rAF never advances the phase).

`useOverlay` now runs in `Popover` against the trigger's open state, the
way `Modal` and `Tray` already did.

Also restore focus to the day grid when `Escape` steps back from the
calendar's month or year panel: the grid the user was in unmounts, and
without claiming focus it fell out of the popover, swallowing the next
`Escape`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The month and year segments were `small` buttons with an `h6` preset and
hand-rolled padding, so they stood 28px tall next to the 24px navigation
arrows and their hover fill crowded the top of the popover. They are now
plain `xsmall` clear buttons with no style overrides.

The plain-title fallback (a decade range, or a calendar with navigation
turned off) follows the same type scale, so the header doesn't switch
weights depending on whether its title happens to be interactive.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
498.78 kB against a 497 kB limit. `main` rebuilds at 496.25 kB, so the
month/year navigation costs +2.53 kB: the shared calendar panel, header
and period grid, plus eleven strings across twelve locales. The Button
budget is unchanged at 122.3 kB, so this reaches nobody who doesn't
import a calendar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tenphi
tenphi merged commit 1394b03 into main Aug 14, 2026
15 checks passed
@tenphi
tenphi deleted the andrew/cub-3903-improve-calendar-component-in-ui-kit branch August 14, 2026 16:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant