Skip to content

[temporal] New Calendar component#1973

Open
flaviendelangle wants to merge 436 commits intomui:masterfrom
flaviendelangle:calendar-component
Open

[temporal] New Calendar component#1973
flaviendelangle wants to merge 436 commits intomui:masterfrom
flaviendelangle:calendar-component

Conversation

@flaviendelangle
Copy link
Member

@flaviendelangle flaviendelangle commented May 27, 2025

Part of #1709

Step 1: Extract a minimal version of the Calendar component from the X repo

Preview: https://deploy-preview-1973--base-ui.netlify.app/react/components/calendar

In this PR

Adapters

The adapters expose a unified set of methods in order to support several date libraries. The MUI X Date and Time Pickers currently support 8 adapters (Day.js, Luxon, Moment, Date Fns v3+v4, Date Fns v2, Moment Jalaali, Moment Hijri and Date Fns Jalali).
One of the main goal of this PR should be to decide if we want to keep this abstraction.


Decided to hide the adapter initially and use the date-fns adapter internally.

  • Import the adapter internals
  • Remove the properties only used by the field components (to keep the PR "light")
  • Improve some methods (create a adapter.now() method to avoid polymorphism on adapter.date())
  • Remove most unused formats and give more explicit names to the remaining ones (to know if they have leading zeros or not)
  • Import and update the Luxon adapter
  • Migrate the adapter tests

Manager / validation

The manager expose methods to interact with the value regarless of its type (date, time, date time, date range, time range and date time range).
This allow to do stuff like validate the value, set the timezone on a value, compare two values etc...
It allows to share a lot of code between the Calendar and the Range Calendar (see useSharedCalendarRoot).
On the field components, it will allow to have almost all the code agnostic of the value type and to create very light wrappers for each value type.

  • Import the manager internals
  • Remove the properties the the calendar doesn't use (to keep the PR "light")
  • Import and update the date manager
  • Import the validation internals
  • Import and update the date validation
  • Rework the date validation API (remove disableFuture and disablePast, replace shouldDisableDate with a isDateUnavailable that is not part of the validation per say like on React Aria)
  • Add tests

Calendar component

  • Migrate my PoC from the MUI X repo without the month view, the year view and the range calendar parts
  • Use the new store/selector introduced for the Select
  • Create a first version of the doc page with a CSS Module example
  • Create a few experiments
  • Implement a11y keyboard navigation
    • Fix issues with typing for DOM updates/syncs (onKeyDown)
  • Add example of lazy loading inside of days
  • Add Tailwind examples
  • Add tests (not sure how deep I'll go 😆 )
  • Update to use the new eventDetails structure
  • Add transitions to month navigation

Finalization

  • Fix animation example with motion/react
  • Update Calendar header animation to use slide or fade instead of flipping
  • Holding the month next/prev buttons down with the pointer should repeatedly increment the changes (like Number Field)
  • Refine keyboard shortcut navigation with checks for disabled days
  • Rename TemporalLocaleProvider LocalizationProvider
  • Assert if the SetMonth part is actually needed
    Removed it, extended the public context with setVisibleDate and added an example with month and year selects
  • Quicker year navigation (month and year select)
  • Add a demo showing how to change the week day
  • Check if we need to improve aria of the week header (remove aria-hidden)
    • It's less reliable (behavior differs between SRs)
    • Hard to ensure reliable labelling (relationship between header (i.e., month and year)) without controlling the UI/rendering
    • Wouldn't improve performance since we'd still need nicer label on every day (28th instead of plain 28)

Follow-ups

  • Refine/Rework animations when navigating quickly (throttle, queue)?
    The current approach doesn't work well for calendar when navigating quickly
  • Add Tailwind examples - I propose to avoid bloating this PR with unnecessary files. 🙈
  • Refine composite and looping implementation to make it work in case the tr would be rendered with role="row"
  • Make the weekday rendering a bit more flexible (without relying on hooks)
  • Decide on an outside of month days behvaior.
    • Should it select a day and navigate like on native Chrome calendar and X Pickers?
    • Should it be visible by default? Hidden by default? (Make it controllable)

@flaviendelangle flaviendelangle self-assigned this May 27, 2025
@flaviendelangle flaviendelangle added the type: new feature Expand the scope of the product to solve a new problem. label May 27, 2025
@netlify
Copy link

netlify bot commented May 27, 2025

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit a22f4e4
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69b2e070723fa20008f3a5bf
😎 Deploy Preview https://deploy-preview-1973--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@pkg-pr-new
Copy link

pkg-pr-new bot commented May 28, 2025

commit: e2c0b6a

@mui-bot
Copy link

mui-bot commented May 30, 2025

Bundle size report

Bundle Parsed size Gzip size
@base-ui-components/react 🔺+17KB(+5.44%) 🔺+4.81KB(+4.83%)

Details of bundle changes

Generated by 🚫 dangerJS against f3c2aa5

@github-actions github-actions bot added PR: out-of-date The pull request has merge conflicts and can't be merged. and removed PR: out-of-date The pull request has merge conflicts and can't be merged. labels Jun 2, 2025
@flaviendelangle flaviendelangle force-pushed the calendar-component branch 4 times, most recently from 51ba17e to 50a9781 Compare June 3, 2025 12:50
@flaviendelangle flaviendelangle marked this pull request as ready for review June 3, 2025 16:14
@michaldudak
Copy link
Member

I'm wondering if a template-based API (not fully composable, so instead of forcing users to render each week and day, they'd only specify a template and we will be responsible for rendering) wouldn't be a better choice here.

I suppose (feel free to correct me, though) that in most (if not all) cases, users will simply map weeks and days arrays to DayGridRow and DayGridCell. Having a DayTemplate and WeekTemplate could simplify the required JSX.

@flaviendelangle
Copy link
Member Author

@michaldudak could you give me a basic JSX example of what it could look like?

@michaldudak
Copy link
Member

michaldudak commented Jun 4, 2025

Something along these lines:

<Calendar.DayGridBody className={styles.DayGridBody}>
  <Calendar.DayGridRowTemplate>
    { (week, props) => <div {...props} className={styles.DayGridRow} /> }
  </Calendar.DayGridRowTemplate>

  <Calendar.DayGridCellTemplate>
    { (day, props) => <div {...props} className={styles.DayGridCell}><Calendar.DayButton className={styles.DayButton} /></div> }
  </Calendar.DayGridCellTemplate>     
</Calendar.DayGridBody>

(alternatively, with a render prop instead of children)


EDIT: I just realized this could be problematic with SSR, as templates would have to register themselves in their parent. We could work around this by looking for them with React.children.

@flaviendelangle
Copy link
Member Author

I have mixed feelings with the shortcuts on the DX here.
Is the few lines of JSX removed worth the new concept that people will have to learn? That's always the question I guess.

In your example, how would people style the other nodes? (mostly GridHeader, GridHeaderBody and GridHeaderCell?)
In my example, I think I'm adding CSS to every single DOM node.
Or how they would pass other props like event handlers? (probably more niche for sure).

I do agree with you that for bigger component, the fully composable approach becomes quite verbose. And that, for the Calendar, almost all app will have similar JSX if they use a fully composable approach.
I do think the fully composable approach has value and I wouldn't remove it (because it is the most flexible one). But I'm interested to see how we could come up with a alternative and complementary DX.
React Aria has a short version:

  <CalendarGrid>
    {(date) => <CalendarCell date={date} />}
  </CalendarGrid>

But it only works because they ship CSS by default.
We could do the same and expose a classes prop to allow people to style the nodes, but it's not super "Base UI".

@flaviendelangle
Copy link
Member Author

flaviendelangle commented Jun 4, 2025

Another point, I decided to let the user map the cells, to be super flexible, but we could of course discuss replacing:

  <Calendar.DayGridRow value={week}>
    {({ days }) =>
      days.map((day) => (
        <Calendar.DayGridCell value={day}>
          <Calendar.DayButton />
        </Calendar.DayGridCell>
      ))
    }
  </Calendar.DayGridRow>

With:

  <Calendar.DayGridRow value={week}>
    {({ day }) =>
      <Calendar.DayGridCell value={day}>
        <Calendar.DayButton />
      </Calendar.DayGridCell>
    }
  </Calendar.DayGridRow>

It's what React Aria does on their Date Field component
You loose the ability to skip some days, add stuff between the days etc... (which I don't have a valid use case for, but I can't guarantee nobody wants to do it), but you have a shorter DX.
(same the the header cell and the grid row)

@colmtuite
Copy link
Contributor

colmtuite commented Jun 5, 2025

@flaviendelangle Thanks for this, excited to see it! I had a quick first look over it.

Chiming in on the recent feedback

I'm wondering if a template-based API (not fully composable, so instead of forcing users to render each week and day, they'd only specify a template and we will be responsible for rendering) wouldn't be a better choice here.
Is the few lines of JSX removed worth the new concept that people will have to learn?

I don't personally feel much pain in the JSX. On the contrary, I'm actually quite surprised at how terse it is. If there are new concepts to learn, or potential issues with SSR, or styling limitations, or content limitations, I'd opt for the usual normal compound pattern. If there are none of those limitations, it's worth considering.

I decided to let the user map the cells, to be super flexible, but we could of course discuss replacing:

This seems more promising to me. I prefer it without the map. If we can't think of a valid use case we'd be restricting, it seems we should do this?

Some questions/suggestions of my own:

  • In June, why are dates 6–10 missing? Can I control that somehow?
  • Can you add isDateUnavailable and timezone to .Root in the demo please? Just so I can see the syntax.
  • What is the thinking behind exposing <Calendar.KeyboardNavigation> as a component, rather than just always enabling keyboard navigation and not having a component for it?
  • Drop "Day" and just call it Calendar.Grid?
  • What is the purpose of the nativeButton prop and what's its use case?
  • Rather than <Calendar.SetVisibleMonth target="previous|next", what do you think about Calendar.Previous and Calendar.Next? Or Calendar.PreviousMonth and Calendar.NextMonth or Calendar.SetPrevious and Calendar.SetNext. I think something like this might be more inline with the conventions we set in our NumberField component.
  • Do we have some mechanism to set the beginning of the week? Some people consider it Sunday, others Monday.
  • How will animation work? Do we need to add the data-starting-style and data-ending-style attrs?
  • How could we animate it vertically, so the next/previous buttons are up/down arrow icons, and the next month slides up from the bottom, and the previous month slides down from the top?
  • What is the purpose of the {({ weeks }) => map? I was expecting to just see the days map, since I don't see any mention of weeks in the calendar demo.

Btw, lmk whenever you want some styling feedback. I assumed it's too early for that, so didn't bother.

@atomiks
Copy link
Contributor

atomiks commented Jun 5, 2025

Feedback


  1. Bug?: I'm assuming focus isn't meant to revert back to the beginning after navigating to a new month and selecting a day
Screen.Recording.2025-06-05.at.2.36.22.pm.mov

  1. What's the purpose of Calendar.KeyboardNavigation? Shouldn't this be implicit inside the Root part?

  1. VoiceOver doesn't announce the new month when navigating down continually (past the last/first date) once it loops. The buttons also don't have an aria-label and say the icon name.

@flaviendelangle
Copy link
Member Author

flaviendelangle commented Jun 5, 2025

This seems more promising to me. I prefer it without the map. If we can't think of a valid use case we'd be restricting, it seems we should do this?

I will remove the map so that we can discuss this version 👍

In June, why are dates 6–10 missing? Can I control that somehow?

I will check why

Can you add isDateUnavailable and timezone to .Root in the demo please? Just so I can see the syntax.

Sure

What is the thinking behind exposing <Calendar.KeyboardNavigation> as a component, rather than just always enabling keyboard navigation and not having a component for it?

What's the purpose of Calendar.KeyboardNavigation? Shouldn't this be implicit inside the Root part?

It did the split recently looking at #1963, but I'm very unsure
My reasoning was to have a smaller bundle size when you don't need keyboard navigaton, but I struggle to find a use case where it doesn't make sense to have it...

What is the purpose of the nativeButton prop and what's its use case?

I just re-used what other components are doing when using useButton
I think it's to support SSR correctly.

Rather than <Calendar.SetVisibleMonth target="previous|next", what do you think about Calendar.Previous and Calendar.Next? Or Calendar.PreviousMonth and Calendar.NextMonth or Calendar.SetPrevious and Calendar.SetNext. I think something like this might be more inline with the conventions we set in our NumberField component.

No big technical challenge here. If we are fine having 6 components it's good for me.
I just think having a shortcut way of going to the previous and next without date manipulation is nice. But not strong preference on the exact DX.

Do we have some mechanism to set the beginning of the week? Some people consider it Sunday, others Monday.

We let the date library handle that.
I need to document it, you can find the MUI X doc here

How will animation work? Do we need to add the data-starting-style and data-ending-style attrs?

I did not explore that at all yet. Do you think it's needed on the initial PR or can it be kept for a follow up?

How could we animate it vertically, so the next/previous buttons are up/down arrow icons, and the next month slides up from the bottom, and the previous month slides down from the top?

For me the current version make no assumption on the rendering layout to we could animate vertically. But I would need to explore and add experiments.

What is the purpose of the {({ weeks }) => map? I was expecting to just see the days map, since I don't see any mention of weeks in the calendar demo.

Not sure to understand this one.
Do you mean you expected a single map in ·Calendar.DayGridBody that would render the days without having one row per week?

Bug?: I'm assuming focus isn't meant to revert back to the beginning after navigating to a new month and selecting a day

Clearly bug
EDIT: Should be fixed now

VoiceOver doesn't announce the new month when navigating down continually (past the last/first date) once it loops. The buttons also don't have an aria-label and say the icon name.

The accessibility is probably far from perfect for now.
I will add aria-label on the buttons.
For the new month, not sure how to achieve that.

@flaviendelangle
Copy link
Member Author

Drop "Day" and just call it Calendar.Grid?

The naming was picked when the calendar supported month and year navigation.
If you think we shouldn't support those views then clearly we can simplify the names of most parts.
If you think we should support those views, then either we keep the prefix for clarity or we say that the day is by far the most widely used view so we can skip the prefix for this one.

@flaviendelangle
Copy link
Member Author

@atomiks React Aria writes "Next" as the aria label of the button that goes to the next month.
To do something similar we would need a localization support inside our component.
I can write the targetted date (since it's formatted by the date library I do have localization with dates).
WDYT?

@atomiks
Copy link
Contributor

atomiks commented Jun 5, 2025

@flaviendelangle For NumberField buttons it defaults the aria-label to English (Increase/Decrease) and the expectation is that aria-label is manually set for localization on the button component

I wonder if the demos should set it manually so it becomes more obvious it needs to be localized

@LukasTy
Copy link
Member

LukasTy commented Mar 10, 2026

@LukasTy the environment variable is already added within docs-infra:

https://github.com/mui/mui-public/blob/96a7c608aa3603924622f70e8fe30cf084d59909/packages/docs-infra/src/withDocsInfra/withDeploymentConfig.ts#L19-L46

Sorry for causing "commotion". I didn't restart my dev server. 🙈

@LukasTy
Copy link
Member

LukasTy commented Mar 11, 2026

Claude Code review

Code Structure

1. CalendarViewport does imperative DOM mutation during render

In CalendarViewport.tsx (lines ~130–156), attributes like data-current, data-starting-style, and data-ending-style are set imperatively on currentContainerRef.current during the render phase — outside of any effect:

// CalendarViewport.tsx — this runs during render, not in an effect
if (currentContainerRef.current) {
  if (isTransitioning) {
    currentContainerRef.current.setAttribute('data-current', '');
    // ...
  } else {
    for (const attribute of DATA_ATTRIBUTES) {
      currentContainerRef.current.removeAttribute(attribute);
    }
  }
}

This is a React anti-pattern. Render must be pure — side effects on DOM refs belong in useLayoutEffect. Under concurrent features (Suspense, useTransition, offscreen rendering), React may call render without committing, meaning these DOM mutations could fire on an uncommitted tree or fire twice. The useIsoLayoutEffect is already used elsewhere in this file, so moving this block into one would be consistent and correct.

2. React.createElement with dynamic localName is unguarded

In CalendarViewport.tsx (~line 114):

React.createElement(previousContentNode.localName, {
  className: currentContainerRef?.current?.className,
  // ...
})

localName could be anything — a web component tag, an SVG element, or even something unexpected if the consumer uses render to swap the element type. There's no validation. A small allowlist would make this safe:

const ALLOWED_TAGS = new Set(['tbody', 'div', 'section']);
const tag = ALLOWED_TAGS.has(previousContentNode.localName)
  ? previousContentNode.localName
  : 'div';

3. SharedCalendarStore mixes concerns and has a growing surface area

The store class (~250 lines) handles value management, visible date management, navigation direction, day grid registration, date selection with timezone conversion, and observer wiring. This is a lot for one class. The currentMonthDayGrid registration pattern in particular is a mutable side channel:

// SharedCalendarStore.ts
private currentMonthDayGrid: Record<number, TemporalSupportedObject[]> = {};

public registerCurrentMonthDayGrid = (week, days) => {
  const weekTime = this.state.adapter.getTime(week);
  if (this.currentMonthDayGrid[weekTime] == null) {
    this.currentMonthDayGrid[weekTime] = days;
  }
  return () => { delete this.currentMonthDayGrid[weekTime]; };
};

This is called from CalendarDayGridRow's useEffect and then read synchronously inside keyboard handlers in useSharedCalendarDayGridBody. The data flow is: effect → mutable object → event handler → queueMicrotask → read new mutable object. This is fragile — if any render or effect timing changes, the grid data could be stale. Consider making this part of the reactive state (even if updated via an effect) so consumers can rely on it consistently.

4. CalendarDayGridHeader unconditionally sets aria-hidden: true

// CalendarDayGridHeader.tsx
const element = useRenderElement('thead', componentProps, {
  ref: forwardedRef,
  props: [{ 'aria-hidden': true }, elementProps],
});

This hides the weekday column headers from screen readers entirely. The native table model associates <th> headers with <td> cells — hiding the <thead> breaks that association. The DayButton compensates with a full-date aria-label, but that means the table's role="grid" semantics are incomplete. At minimum this should be configurable via a prop, not hardcoded.

5. LocalizationProvider creates a new adapter instance on every locale change

// LocalizationProvider.tsx
const adapterContextValue = React.useMemo(
  () => ({ adapter: new TemporalAdapterDateFns({ locale: temporalLocale }) }),
  [temporalLocale],
);

This is correct in terms of memo deps, but the wrapping object { adapter: ... } is created fresh every time temporalLocale changes, which triggers a context cascade through every consumer. Since temporalLocale is likely stable for the lifetime of the app, this is fine in practice, but worth noting that if anyone passes an unstable locale object, the entire calendar tree re-renders.

Performance

6. useSharedCalendarDayGridBody rebuilds disabledIndices from the full itemMap on every map change

// useSharedCalendarDayGridBody.ts
const disabledIndices = React.useMemo(() => {
  const output: number[] = [];
  for (const itemMetadata of itemMap.values()) {
    if (itemMetadata?.index != null && !itemMetadata.focusable) {
      output.push(itemMetadata.index);
    }
  }
  return output;
}, [itemMap]);

For a 42-cell grid this is trivial, but the pattern creates a new array on every itemMap reference change, which then triggers downstream effects in CompositeRoot via disabledIndices. If the map changes but disabled indices don't, there's wasted work. A structural comparison or a stable-reference optimization would help.

7. Keyboard handler in useSharedCalendarDayGridBody does a full sort on every PageUp/PageDown

// useSharedCalendarDayGridBody.ts — inside handleKeyboardNavigation
const gridDays = Object.values(store.getCurrentMonthDayGrid())
  .flat()
  .sort((a, b) => adapter.getTime(a) - adapter.getTime(b));

This runs on every PageUp/PageDown keypress. Since currentMonthDayGrid is keyed by weekTime (which is already chronological for weekly rows), the sort is likely redundant — the weeks are registered in DOM order. If ordering can't be guaranteed, at least cache the sorted result until the grid changes rather than recomputing on each keypress.

8. day.toString() used as React keys throughout all examples and render-prop children

The examples and internal render-prop resolution all use day.toString() / week.toString():

weeks.map(children)  // children receives (week, index, weeks)
// consumer does: key={day.toString()}

Date.toString() produces a verbose locale string like "Tue Feb 04 2025 00:00:00 GMT+0000". Using day.getTime() (a number) is both cheaper to produce and cheaper for React's reconciler to compare.

9. selectors.tsgetDateKey uses adapter.format for map lookups

const getDateKey = (adapter: TemporalAdapter, date: TemporalSupportedObject) =>
  adapter.format(date, 'localizedNumericDate');

This is called inside isDayButtonTabbableSelector which runs for every day cell. adapter.format involves locale-aware formatting — it's significantly more expensive than a numeric key. Since this is just used as a map key, adapter.getTime(date) or a simple ${year}-${month}-${day} string would be both faster and more reliable (locale-formatted strings could collide across different date-fns locales).

10. CalendarDayButton has redundant useMemo for isCurrent

const isCurrent = React.useMemo(
  () => adapter.isSameDay(value, adapter.now(adapter.getTimezone(value))),
  [adapter, value],
);

The deps are [adapter, value], but adapter.now() returns the current time — it changes every millisecond. This memo will return a stale result if the component stays mounted past midnight. Since adapter and value are stable within a render, the memo is effectively doing nothing that a plain function call wouldn't — but it also won't invalidate when it should (at midnight). Either compute it without memo or add a more explicit "today" value from the store.

API Design Issues

11. isSetMonthButtonDisabledSelector receives disabledProp as both a bound state dep and a runtime arg

const isDisabled = useStore(
  store,
  selectors.isSetMonthButtonDisabled,
  disabledProp,    // passed as arg
  targetDate,
  disabledProp,    // passed again as arg
);

The selector signature is:

(adapter, validationProps, isCalendarDisabled,
 disabled: boolean | undefined,
 targetDate: TemporalSupportedObject,
 disabledProp?: boolean | undefined) => { ... }

disabled (4th param) and disabledProp (6th param) are the same value passed twice. The selector then short-circuits on disabledProp but also checks disabled. This is confusing — the distinction between the two isn't clear, and passing the same value twice suggests the API could be simplified.

12. useCalendarContext spreads and creates a new object on every render

export function useCalendarContext(): CalendarContext {
  const store = useSharedCalendarRootContext();
  const calendarPublicContext = useStore(store, selectors.publicContext);
  return { ...calendarPublicContext, setVisibleDate: store.setVisibleDate };
}

Every call returns a new object reference (due to the spread), so any component using this hook in a dependency array will re-run effects every render. Since store.setVisibleDate is a stable method, this could be memoized:

return React.useMemo(
  () => ({ ...calendarPublicContext, setVisibleDate: store.setVisibleDate }),
  [calendarPublicContext, store.setVisibleDate],
);

13. executeAfterItemMapUpdate is a mutable ref used as a deferred callback queue

const executeAfterItemMapUpdate = React.useRef<(newMap: any) => void>(null);

const handleItemMapUpdate = (newMap: typeof itemMap) => {
  setItemMap(newMap);
  if (executeAfterItemMapUpdate.current) {
    queueMicrotask(() => {
      executeAfterItemMapUpdate.current?.(newMap);
      executeAfterItemMapUpdate.current = null;
    });
  }
};

This is a hand-rolled callback-after-state-update pattern. The queueMicrotask means the callback fires after the current task but before the next paint — however, it fires before React has committed the new state. If focusItemFromMap (called inside) tries to focus elements, those elements may not be in the DOM yet. This works today because setItemMap triggers a synchronous re-render in the microtask gap, but it's relying on React internals. A safer pattern would be useEffect watching for map changes, or flushSync if synchronous focus is truly needed.

Test Quality

14. Tests have extensive boilerplate but lack edge case coverage for the Viewport

The CalendarViewport component — the most complex piece with imperative DOM cloning, animation lifecycle, and abort controller management — has zero tests. Given that this is where most bugs will surface (rapid navigation, animation interruption, SSR hydration), this is a significant gap.

15. Keyboard test helpers could be DRYer

The renderUncontrolledCalendar and renderFocusableWhenDisabledCalendar helpers in the keyboard test file differ only in whether focusableWhenDisabled is set on DayButton. A parameterized helper would reduce the ~30-line duplication.

Summary

The biggest risks are the render-time DOM mutations in CalendarViewport (concurrent mode hazard), the mutable currentMonthDayGrid side channel (fragile timing), and the aria-hidden header (a11y regression). The performance items (getDateKey formatting, redundant sort, toString() keys) are individually small but compound across 35–42 cells per month. The selector duplication (disabledProp passed twice) and useCalendarContext reference instability are straightforward fixes that would improve downstream memoization.

@atomiks
Copy link
Contributor

atomiks commented Mar 11, 2026

Codex Review

Overview

This patch adds a new Calendar API, the date and localization plumbing it depends on, and a large set of docs and demos around that surface. The overall structure is promising, but the current branch still has release-blocking gaps in day-button interaction behavior and multi-month keyboard navigation.

Findings

1. 🔴 [Blocking] Outside-month day buttons still perform selection

Impact: Outside-month days are exposed as disabled and styled as non-interactive, but clicking them still changes the value. That is both an accessibility mismatch and a behavior footgun: assistive tech is told the control is disabled while pointer users can still activate it.

Evidence: packages/react/src/calendar/day-button/CalendarDayButton.tsx sets aria-disabled when isOutsideCurrentMonth is true, but the click guard only checks isUnavailable || isDisabled. A direct render-and-click repro on this branch still fired onValueChange for January 31 while February was the visible month.

Recommendation: Treat isOutsideCurrentMonth as a true disabled state in the click path, and ideally feed it into the underlying button-disabled handling so the DOM semantics and behavior stay aligned.

2. 🔴 [Blocking] PageUp/PageDown in offset grids advances from the wrong month

Impact: The public offset API already supports multi-month layouts, but keyboard paging inside secondary grids still calculates the next and previous month from the root visibleMonth. In a two-month calendar, paging from the right-hand month advances from February to March instead of from March to April.

Evidence: packages/react/src/calendar/day-grid-body/useSharedCalendarDayGridBody.ts derives targetMonth from visibleMonth instead of the body-local month when handling PageUp/PageDown and month-wrapping arrow navigation. Existing coverage in packages/react/src/calendar/day-grid-body/CalendarDayGridBody.test.tsx only verifies that multiple offsets render, and packages/react/src/calendar/day-grid-body/CalendarDayGridBody.keyboard.test.tsx never renders offset > 0. A direct repro on this branch focused March 15 in an offset={1} grid, pressed PageDown, and received 2025-03-01 instead of 2025-04-01 in onVisibleDateChange.

Recommendation: Drive month paging and looping from the body-local month for that grid, and add regression coverage for keyboard navigation in multi-month calendars.

Confidence: 4/5

High confidence based on a full-branch pass across the calendar/store/adapter/shared-hook changes, focused suite coverage on the touched areas, and direct repros for the two interaction bugs above.

Notes

Focused suites currently pass but do not cover the failing cases above: pnpm test:jsdom CalendarDayGridBody --no-watch, pnpm test:jsdom CalendarDayButton --no-watch, pnpm test:jsdom CalendarIncrementMonth --no-watch, pnpm test:jsdom CalendarDecrementMonth --no-watch, pnpm test:jsdom NumberFieldRoot --no-watch, pnpm test:jsdom TemporalAdapterDateFns --no-watch, and pnpm test:jsdom TemporalAdapterLuxon --no-watch.

Calendar.Viewport still has no targeted tests, so rapid-transition animation behavior remains the main residual area I would recheck after the blockers above are fixed.

@LukasTy
Copy link
Member

LukasTy commented Mar 12, 2026

  1. CalendarViewport does imperative DOM mutation during render

Moved DOM manipulation into an effect.
In ant case, this component will need a follow-up to handle animation batching.

  1. React.createElement with dynamic localName is unguarded

Went with the recommendation to whitelist certain elements.

  1. SharedCalendarStore mixes concerns and has a growing surface area

Refactored code to rely on precomputed newMonth grid in memory instead of waiting for the new month DOM to be mounted.

  1. CalendarDayGridHeader unconditionally sets aria-hidden: true

I've briefly checked this problem and it's not a quick fix.
To replicate the month and day spelling it will need some extra effort and might not even be worth it.
What if someone doesn't render a header and we relate all days with the header? It's gonna break a11y...

  1. LocalizationProvider creates a new adapter instance on every locale change

I don't see room for improvement here. We need an update to the locale to trigger downstream re-renders.

  1. useSharedCalendarDayGridBody rebuilds disabledIndices from the full itemMap on every map change

Used a ref to avoid unnecessary re-assignments.

  1. Keyboard handler in useSharedCalendarDayGridBody does a full sort on every PageUp/PageDown

The assumption was false, but I improved it by caching the result and reusing it in two places instead of potentially recalculating it for different navigation.
No longer relevant after change in 3rd point, since this logic is removed.

  1. day.toString() used as React keys throughout all examples and render-prop children

Updated usages in demos and tests.

  1. selectors.tsgetDateKey uses adapter.format for map lookups

Updated to use getTime() instead.

  1. CalendarDayButton has redundant useMemo for isCurrent

Removed the redundant useMemo wrapping.

11. isSetMonthButtonDisabledSelector receives disabledProp as both a bound state dep and a runtime arg

Removed the redundant prop.

12. useCalendarContext spreads and creates a new object on every render

Memoized the hook return.

13. executeAfterItemMapUpdate is a mutable ref used as a deferred callback queue

Replaced queueMicrotask with useIsoLayoutEffect.

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

Labels

scope: pickers Changes related to the date/time pickers. scope: temporal Changes related to the Temporal components. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

Development

Successfully merging this pull request may close these issues.