mui / mui-x

MUI X: Build complex and data-rich applications using a growing list of advanced React components, like the Data Grid, Date and Time Pickers, Charts, and more!
https://mui.com/x/
4.57k stars 1.34k forks source link

[pickers] New unstyled component: Calendar #15598

Open flaviendelangle opened 4 days ago

flaviendelangle commented 4 days ago

Part of #15558

This component would be the Base-UI-DX component to edit day, month and year for a single date. It would the fundation for <MonthCalendar />, <YearCalendar /> and <DateCalendar /> on the MUI X version.

Basic examples

Most basic example (only day view, simple header)

<Calendar.Root>
  <Calendar.Header.Root>
    <Calendar.Navigation.SetMonth target="previous" />
    <Calendar.Header.Label />
    <Calendar.Navigation.SetMonth target="next" />
  </Calendar.Header.Root>
  <Calendar.Days.Root>
    <Calendar.Days.Header>
      {value => <Calendar.Days.Label value={value} />}
    </Calendar.Days.Header>
    <Calendar.Days.Content>
      {weekValue => (
        <Calendar.Days.WeekLine value={weekValue}>
          {dayValue => <Calendar.Days.Cell value={dayValue} />}
        </Calendar.Days.WeekLine>
      )}
    </Calendar.Days.Content>
  </Calendar.Days.Root>
</Calendar.Root>

Basic example with the day, month and year view

<Calendar.Root>
  <Calendar.Header.Root>
    <div>
      <Calendar.Navigation.SetMonth target="previous" />
      <Calendar.Navigation.SetView target="month">
        <Calendar.Header.Label format="MMMM" />
      </Calendar.Navigation.SetView>
      <Calendar.Navigation.SetMonth target="next" />
    </div>
    <div>
      <Calendar.Navigation.SetYear target="previous" />
      <Calendar.Navigation.SetView target="year">
        <Calendar.Header.Label format="YYYY" />
      </Calendar.Navigation.SetView>
      <Calendar.Navigation.SetYear target="next" />
    </div>
  </Calendar.Header.Root>
  <Calendar.Days.Root>
    <Calendar.Days.Header>
      {value => <Calendar.Days.Label value={value} />}
    </Calendar.Days.Header>
    <Calendar.Days.Content>
      {weekValue => (
        <Calendar.Days.WeekLine value={weekValue}>
          {dayValue => <Calendar.Days.Cell value={dayValue} />}
        </Calendar.Days.WeekLine>
      )}
    </Calendar.Days.Content>
  </Calendar.Days.Root>
  <Calendar.Months.Root>
    {value => <Calendar.Months.Cell value={value} />}
  </Calendar.Months.Root>
  <Calendar.Years.Root>
    {value => <Calendar.Years.Cell value={value} />}
  </Calendar.Years.Root>
</Calendar.Root>

I need to clarify how we handle the view switch.

Basic example of Calendar.Header.*

<Calendar.Header.Root>
  <Calendar.Navigation.SetMonth target="previous" />
  <Calendar.Header.Label />
  <Calendar.Navigation.SetMonth target="next" />
</Calendar.Header.Root>

Example of Calendar.Header.* with month and year navigation

Here is the structure that would be used to recreate the navigation of the MD3 Date Picker:

image

<Calendar.Header.Root>
  <div>
    <Calendar.Navigation.SetMonth target="previous" />
    <Calendar.Navigation.SetView target="month">
      <Calendar.Header.Label format="MMMM" />
    </Calendar.Navigation.SetView>
    <Calendar.Navigation.SetMonth target="next" />
  </div>
  <div>
    <Calendar.Navigation.SetYear target="previous" />
    <Calendar.Navigation.SetView target="year">
      <Calendar.Header.Label format="YYYY" />
    </Calendar.Navigation.SetView>
    <Calendar.Navigation.SetYear target="next" />
  </div>
</Calendar.Header.Root>

Basic example of Calendar.Days.*

<Calendar.Days.Root>
  <Calendar.Days.Header>
    {day => <Calendar.Days.Label />}
  </Calendar.Days.Header>
  <Calendar.Days.Content>
    {weekValue => (
      <Calendar.Days.WeekLine value={weekValue}>
        {value => <Calendar.Days.Cell value={value} />}
      </Calendar.Days.WeekLine>
    )}
  </Calendar.Days.Content>
</Calendar.Days.Root>

Component list

Misc: Calendar.*

Header: Calendar.Header.*

Day view: Calendar.Days.*

Month view: Calendar.Months.*

Year view: Calendar.Years.*

Navigation: Calendar.Navigation.*

[!TIP] I'm not prefixing the components below with Calendar.Header because people could use those buttons outside the header, it just need to be inside <Calendar.Root />. We could imagine having a view switch inside a footer.

Props that will remain in the MUI X layer

The following props won't be present in the Base UI X component but only on the MUI X one because they only apply the style:

To clarify

Planned work

This is very early stage but here is a basic idea of what the migration could look like:

  1. Create the Calendar component in @mui/x-date-pickers/internals/base-ui/Calendar (no public doc, only private one). Requires to have Base UI as a dep of our package so we need to wait for the stable release (or at the very least the beta)
  2. Reach feature parity with @mui/x-date-pickers/DateCalendar
  3. Migrate @mui/x-date-pickers/DateCalendar to use the new component
  4. Make the new component public (probably in a dedicated @base-ui/x-date-pickers package)

Search keywords:

cherniavskii commented 3 days ago

Here is the structure that would be used to recreate the navigation of the MD3 Date Picker:

In that example, will Calendar.Header.Label also render a dropdown for month selection? Or will there be another component for that?

cherniavskii commented 3 days ago

Would *.*.Cell components accept a render prop for custom cells? Or should they be replaced by custom components?

flaviendelangle commented 3 days ago

In that example, will Calendar.Header.Label also render a dropdown for month selection? Or will there be another component for that?

<Calendar.Header.Label /> would only render the current label inside a div (span ?), with no behavior attaches. It's mostly a shortcut to have a default label without introducing utility hooks here. Maybe this component is not even that useful, but I think it's a nice shortcut.

If you want to change month, then you can wrap it as follow:

<Calendar.Navigation.SetView target="month">
  <Calendar.Header.Label format="MMMM" />
</Calendar.Navigation.SetView>

And then when the month view is handled by Calendar.Months.* components. Overall your code would look something like that (it just misses some way of hiding the views that are not the current views but I need to think about the DX here):

<Calendar.Root>
  <Calendar.Header.Root>
    <div>
      <Calendar.Navigation.SetMonth target="previous" />
      <Calendar.Navigation.SetView target="month">
        <Calendar.Header.Label format="MMMM" />
      </Calendar.Navigation.SetView>
      <Calendar.Navigation.SetMonth target="next" />
    </div>
    <div>
      <Calendar.Navigation.SetYear target="previous" />
      <Calendar.Navigation.SetView target="year">
        <Calendar.Header.Label format="YYYY" />
      </Calendar.Navigation.SetView>
      <Calendar.Navigation.SetYear target="next" />
    </div>
  </Calendar.Header.Root>
  <Calendar.Days.DefaultLayout>
    {value => <Calendar.Days.Cell value={value} />}
  </Calendar.Days.DefaultLayout>
  <Calendar.Months.Root>
    {value => <Calendar.Months.Cell value={value} />}
  </Calendar.Months.Root>
  <Calendar.Years.Root>
    {value => <Calendar.Years.Cell value={value} />}
  </Calendar.Years.Root>
</Calendar.Root>

If you don't want a month view but instead you want to render some month picking UI inside a drop down in the header, you could do something like the following (I did not check the DX of the Base UI Popover in depth, it's just to get the idea):

<Popover.Root>
  <Calendar.Header.Label format="MMMM" render={<Popover.Trigger />} />
  <Popover.Popup>
    <Calendar.Months.Root>
      {month => <Calendar.Months.Cell value={month} />}
    <Calendar.Months.Root>
  </Popover.Popup>
</Popover.Root>

Since it's the DX of the unstyled component, I'm trying to be as little optionated as possible about the end UX.

flaviendelangle commented 3 days ago

Would ..Cell components accept a render prop for custom cells? Or should they be replaced by custom components?

For me any component built for composition in an unstyled package should follow some common DX and the render prop is one of them (another one would be the className prop with a string | (state) => string signature).

So yes for me we would be able to do:

const MyCustomDay = styled('button')({}) // or any other way of creating and styling a component

<Calendar.Days.DefaultLayout>
  {value => <Calendar.Days.Cell value={value} render={<MyCustomDay />} />}
</Calendar.Days.DefaultLayout>

but also:

const MyCustomDay = styled(Calendar.Days.Cell)({});

<Calendar.Days.DefaultLayout>
  {value => <MyCustomDay />}
</Calendar.Days.DefaultLayout>

but also:

<Calendar.Days.DefaultLayout>
  {value => <Calendar.Days.Cell className={state => clsx('day', state.selected && 'day-selected')} />}
</Calendar.Days.DefaultLayout>

Here I'm following 100% the Base UI DX (or at least what I understand of it, I might get some things wrong)

KenanYusuf commented 3 days ago

What's the purpose of Calendar.Days.DefaultLayout?

flaviendelangle commented 3 days ago

What's the purpose of Calendar.Days.DefaultLayout?

React Aria supports two DX:

I'm proposing to also have this shortcut notation (because it makes the composition easier to get started with). But I'm proposing to split the two DX into two distinct components to simplify the explanation for each one of those (that's clearly debatable and purely a product topic, on the engineering side both are easily doable).

So:

I guess we have three ways of doing it:

  1. We don't support DX n°1. Base UI is more strict than React Aria and always have 1 component = 1 DOM element. But I don't think it will scale that well for X
  2. We support DX n°1 in <Calendar.Days.Root /> just like React Aria does both on <CalendarGrid />
  3. We keep two distinct components.

I like 3. because it could allow us to create some shared nomenclature for the few components that have a richer DOM structure (I'm thinking about the virtualization engine of the Tree View of perhaps the grid if we have composition someday). That way people could learn that unless a component starts with Default* (or another prefix), then they can safely assume that it only has one DOM element.

KenanYusuf commented 3 days ago
  1. We don't support DX n°1. Base UI is more strict than React Aria and always have 1 component = 1 DOM element.

Got it. I like this option.

With any more than 1 DOM element per component, we have the same customization issues that we are trying to solve with composition, right?

flaviendelangle commented 3 days ago

With any more than 1 DOM element per component, we have the same customization issues that we are trying to solve with composition, right?

Yes Which is why for me it's mandatory to have a DX with 1 component = 1 DOM element (which React Aria doesn't even have since you don't own the week DOM element :grimacing: ).

But the question here would more be: should we only provide the version where 1 component = 1 DOM element or are there use cases where providing both the 1 component = 1 DOM element and slightly higher level component make sense? React Aria sets classes on its elements (unlike Base UI) which makes the composition problem a lot less critical.

If we are not sure its bring value, we can definitely wait before adding it to see if there is traction.

KenanYusuf commented 3 days ago

Which is why for me it's mandatory to have a DX with 1 component = 1 DOM element (which React Aria doesn't even have since you don't own the week DOM element 😬 ).

Agree with this 👍 . And yeah, the React Aria approach is restrictive.

I'm strongly in favor of only supporting 1 component = 1 DOM for our composable components. It's less to maintain and less to document. And with less API to document, less confusion for users as they only have to learn one way to do things.

flaviendelangle commented 2 days ago

I'm strongly in favor of only supporting 1 component = 1 DOM for our composable components

I can remove Calendar.Days.DefaultLayout since it's benefits are not clear. But I think we will have use cases where we won't want to always have this 1 component = 1 DOM element, especially for the virtualization engine. But we'll see when we actually try to design a composable API for such use cases.

I'll update the document :+1:

KenanYusuf commented 2 days ago

I do quite like how the components are broken down into subcomponents e.g. Calendar.Days.Header and Calendar.Header.Label. It reads really nicely. I wonder if we need the third level of depth though, it could be:

<Calendar.Root>
  <Calendar.Header>
    <Calendar.SetMonth target="previous" />
    <Calendar.HeaderLabel />
    <Calendar.SetMonth target="next" />
  </Calendar.Header>
  <Calendar.Days>
    <Calendar.DaysHeader>
      {value => <Calendar.DaysLabel value={value} />}
    </Calendar.DaysHeader>
    <Calendar.DaysContent>
      {weekValue => (
        <Calendar.DaysWeekLine value={weekValue}>
          {dayValue => <Calendar.DaysCell value={dayValue} />}
        </Calendar.DaysWeekLine>
      )}
    </Calendar.DaysContent>
  </Calendar.Days>
</Calendar.Root>

This would be more in line with how they name things on Base UI, e.g.

<NumberField.ScrubArea>
  <NumberField.ScrubAreaCursor />
</NumberField.ScrubArea>

Granted, there are fewer parts to their components. Perhaps the third level works for components with more parts, but I would be curious to get their thoughts on this in particular.

KenanYusuf commented 2 days ago

On a similar note, does having a mega component mean that tree shaking won't work? In the context of data grid, if a user doesn't want a toolbar (composed with Grid.Toolbar.Root etc), will this still be in their bundle? This might not be as relevant to the other X components.

We spoke about having the data grid components under separate imports to get around this. Something like:

import * as Toolbar from '@mui/x-data-grid/Toolbar';
flaviendelangle commented 2 days ago

alendar.Header.Label. It reads really nicely. I wonder if we need the third level of depth though, it could be:

It's one of the main DX question I'd like to answer. My first draft looked a lot like yours <Calendar.DaysCell /> etc... I'll gather the opinion of the rest of the team before settling on a solution here :+1: We should definitely be consistent across our components. IMHO if the Calendar don't use nested components, no component should do it in X, except perhaps Grid.*** if you go this way. So that's a discussion we should clearly have with all the X teams.


does having a mega component mean that tree shaking won't work?

I think it does tree shake. At least for Base UI from what I understood tree shaking of components should work. But tbh I did not test and I might have misunderstood. This is indeed a big reason to split or not those big components. If we have tree shaking, I'd like not to split them because I think the DX is better. But if we don't have tree shaking...

With that being said, for a component like Calendar, you will very likely use 90%+ of the code in most apps. This might differ for other pickers components, and definitely for the grid it's not the same... Personally I think I would prefer to have a Toolbar component on the grid instead of everything hosted in Grid.Toolbar, but I'm not 100% sure...

We spoke about having the data grid components under separate imports to get around this. Something like:

Just in case, Base UI was using import * as Slider and migrated away from it in favor of import { Slider }. I don't remember the exact reasons but I tried to follow their DX in my RFCs (this one has no import though).

cherniavskii commented 2 days ago

I think it does tree shake.

It does tree shake if the package has "sideEffecs": false and module set in package.json (which is the case for MUI X packages). The thing is that tree shaking is only done for the production bundle. In dev mode, the whole package will be imported if you do this:

import { Calendar } from '@mui/x-date-pickers';
// or
import { Toolbar } from '@mui/x-data-grid';

This is the same problem that is described in https://mui.com/material-ui/guides/minimizing-bundle-size/#when-and-how-to-use-tree-shaking

At least for Base UI from what I understood tree shaking of components should work.

In Base UI each component has its own import path: https://base-ui.com/components/react-accordion

import { Accordion } from '@base-ui-components/react/accordion';

I believe this is done to solve the slow dev mode issue I mentioned above.

flaviendelangle commented 1 day ago

It does tree shake if the package has "sideEffecs": false and module set in package.json (which is the case for MUI X packages). The thing is that tree shaking is only done for the production bundle. In dev mode, the whole package will be imported if you do this:

For me we are more talking of being able to tree shake Calendar itself if you don't use <Calendar.Months.Root /> for instance.

For the tree shaking of the root, I fully agree with everything you said :laughing: