Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[pickers] New unstyled component: Calendar #15598

Open
flaviendelangle opened this issue Nov 25, 2024 · 15 comments
Open

[pickers] New unstyled component: Calendar #15598

flaviendelangle opened this issue Nov 25, 2024 · 15 comments
Labels
component: pickers This is the name of the generic UI component, not the React module! proof of concept Studying and/or experimenting with a to be validated approach RFC Request For Comments

Comments

@flaviendelangle
Copy link
Member

flaviendelangle commented Nov 25, 2024

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.*

  • <Calendar.Root />
    offset
    Props:
    • Value props: value, defaultValue, onChange, referenceDate, timezone

    • View props: view, defaultView, onViewChange

    • Validation props: minDate, maxDate, shouldDisableDate etc...

    • Form props: disabled, readOnly

    • Focus props: autoFocus (focusedView, openTo and onFocusedViewChange need to be clarified)

    • fixedWeekNumber

Header: Calendar.Header.*

  • <Calendar.Header.Root />

  • <Calendar.Header.Label />

    Pops:

    • format
      • default: ${utils.formats.month} ${utils.formats.year}
      • type: string

Day view: Calendar.Days.*

  • <Calendar.Days.Root />

    Props:

    • fixedWeekNumber

    • displayWeekNumber

      • default: false
  • <Calendar.Days.Content />

  • <Calendar.Days.WeekLine />

    Props:

    • value
      • required
      • type: { date: PickerValidDate }
  • <Calendar.Days.Cell />

    Props:

    • value
      • required
      • type: { type: 'day', date: PickerValidDate } | { type: 'weekNumber' }

Month view: Calendar.Months.*

  • <Calendar.Months.Root />

  • <Calendar.Months.Cell />

    Props:

    • value
      • required
      • type: { date: PickerValidDate }

Year view: Calendar.Years.*

  • <Calendar.Years.Root />

  • <Calendar.Years.View />

    Props:

    • value
      • required
      • type: { date: PickerValidDate }

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.

  • <Calendar.Navigation.SetMonth />

    Props:

    • target
      • required
      • type: "previous" | "next"
  • <Calendar.Navigation.SetYear />

    Props:

    • target
      • required
      • type: "previous" | "next"
  • <Calendar.Navigation.SetView />

    Props:

    • target
      • required
      • type: "year" | "month" | "day"

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:

  • monthsPerRow
  • yearsPerRow
  • disableHighlightToday (<Calendar.Days.Cell /> will set a data-today attribute on the right day, month and year)
  • showDaysOutsideCurrentMonth (<Calendar.Days.Cell /> will set a data-outside-current-month attribute, name tdb on the right days)
  • renderLoading
  • reduceAnimations
  • loading

To clarify

  • How to handle multiple month visible at once (React Aria spec)? It would be a great addition to the Calendar component, currently only <DateRangeCalendar /> supports multiple calendars. When implementing it, we should be careful with the navigation (see the pageBehavior in React Aria)

  • Should it be Calendar.Days.* or Calendar.DayView.*? (same for Months and Years).
    The problem with Calendar.DayView is that when you have only the "day" view (which is a super common use case), the concept of "view" is just a distraction.

  • Should the components be <Calendar.Header.Label /> or <Calendar.HeaderLabel />? (same for all the others). AFAIK Base UI avoids deep nesting like <Calendar.Header.Label />, but maybe we are big enough to justify adding this level of depth.

  • How do we handle keyboard navigation in Calendar.Months.* and Calendar.Years.* without being optionated on the layout? People should be able to design a list (like in MD3) or a grid (like we currently have). Maybe we can keep the monthsPerRow / yearsPerRow props on the Base UI X version but use it only for keyboard navigation purpose.

  • The focus part is very unclear for now (which props? which DX?)

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:

@flaviendelangle flaviendelangle added component: pickers This is the name of the generic UI component, not the React module! RFC Request For Comments proof of concept Studying and/or experimenting with a to be validated approach labels Nov 25, 2024
@cherniavskii
Copy link
Member

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
Copy link
Member

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

@flaviendelangle
Copy link
Member Author

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
Copy link
Member Author

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
Copy link
Contributor

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

@flaviendelangle
Copy link
Member Author

flaviendelangle commented Nov 26, 2024

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

React Aria supports two DX:

  • DX n°1:

    <CalendarGrid>
      {(date) => <CalendarCell date={date} />}
    </CalendarGrid>
  • DX n°2:

    <CalendarGrid>
      <CalendarGridHeader>
        {(day) => <CalendarHeaderCell />}
      </CalendarGridHeader>
      <CalendarGridBody>
        {(date) => <CalendarCell date={date} />}
      </CalendarGridBody>
    </CalendarGrid>

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:

  • <Calendar.Days.DefaultLayout /> = <CalendarGrid /> in React Aria for DX n°1
  • <Calendar.Days.Root /> = <CalendarGrid /> in React Aria for DX n°2

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
Copy link
Contributor

KenanYusuf commented Nov 26, 2024

  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
Copy link
Member Author

flaviendelangle commented Nov 26, 2024

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 😬 ).

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
Copy link
Contributor

KenanYusuf commented Nov 26, 2024

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
Copy link
Member Author

flaviendelangle commented Nov 26, 2024

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 👍

@KenanYusuf
Copy link
Contributor

KenanYusuf commented Nov 26, 2024

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
Copy link
Contributor

KenanYusuf commented Nov 26, 2024

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
Copy link
Member Author

flaviendelangle commented Nov 26, 2024

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 👍
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
Copy link
Member

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
Copy link
Member Author

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 😆

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
component: pickers This is the name of the generic UI component, not the React module! proof of concept Studying and/or experimenting with a to be validated approach RFC Request For Comments
Projects
None yet
Development

No branches or pull requests

3 participants