Custom Date Picker (shadcn/ui, Hajri and Arabic–Indic Numerals)

Mohanad Alrwaihy

September 24, 2023

333

1

One of the most important components in the web is the Date Picker and figuring out how to build one or use an existing date picker is essential for the usability of your app.

7 min read

There are many ways to have a Date Picker on your site either by using a component library date picker like MUI Date Picker or using dependencies like react-datepicker or react-day-picker.

Date Picker Options

  • react-datepicker - A classic simple reusable Datepicker component that is widely used around the web and can have a lot of customization options. (Preview, Github)
  • react-datetime-picker - Great looking datepicker out of the box and it's light, fast, and offers a lot of flexibility. Other date picker options are also provided by the creator Wojciech Maj. (Preview, NPM)
  • react-dates - An easily internationalizable, accessible, mobile-friendly datepicker library for the web. (Github, Preview).
  • react-day-picker - DayPicker is a date picker component for React. Renders a monthly calendar to select days. DayPicker is customizable, works great with input fields, and can be styled to match any design. (Preview, Github).

Custom Calendar

In this post I'm going to show how we can create a custom-designed datepicker with these features:

  • Gregorian & Hajri Calendar.
  • Arabic - Indic Numerals.
  • Arabic Translation.

Using shadcn/ui calendar component which is built on top of react-day-picker and uses date-fns for manipulating JavaScritp dates.

Different Numbering Systems:

The spread of Hindu-Arabic numerals in the tradition of European practical mathematics

Preview

Tools

These are the tools I used to build the Custom Date Picker:

Add shadcn/ui calendar

I'm not going the process of adding shadcn/ui to your project you can follow the installation page for more information or check my latest post Here showing how we can use shadcn/ui in our project.

Install Calendar component

To add Shadcn/ui Calendar 👇

POWERSHELL
# NPM
npx shadcn-ui@latest add calendar

# PNPM
pnpm dlx shadcn-ui@latest add calendar

Customize Calendar

We can customize the calendar by accessing the calendar component in /components/ui/calendar.tsx.

Change base style

The DayPicker component has classNames for everything inside the calendar component that can be styled and customized as you like.

Below are my customized style changes 👇

/components/ui/calendar.tsxTSX
    // ...
    <DayPicker
	// ...
      className={cn('w-full overflow-x-auto p-1.5 sm:p-3', className)}
      classNames={{
        months:
          'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0 w-full',
        month: 'space-y-4',
        caption: 'flex justify-center pt-1 relative items-center',
        caption_label: 'text-sm text-center font-medium mx-12',
        nav: 'space-x-1 flex items-center pt-1',
        nav_button: cn(
          buttonVariants({ variant: 'secondary' }),
          'h-7 w-7 bg-transparent p-0 opacity-50 hover:opacity-100'
        ),
        nav_button_previous: 'absolute left-1',
        nav_button_next: 'absolute right-1',
        table: 'space-y-2 w-full',
        head_row: 'flex justify-between gap-1',
        head_cell: 'text-muted-foreground w-9 font-bold text-[0.7rem]',
        row: 'flex mt-2 justify-between',
        cell: 'text-center text-sm w-9 h-9 p-0 relative rounded-lg focus-within:z-20 self-center my-auto flex flex-col items-center justify-center',
        day: cn(
          buttonVariants({ variant: 'ghost' }),
          'w-full p-0 font-normal aria-selected:opacity-100 font-medium'
        ),
        day_selected:
          'bg-foreground text-background focus:bg-foreground focus:text-background',
        day_today: 'bg-accent text-accent-foreground',
        day_outside: 'text-muted-foreground opacity-50',
        day_disabled: 'text-muted-foreground opacity-50',
        day_range_middle:
          'aria-selected:bg-foreground/70 aria-selected:text-background',
        day_hidden: 'invisible',
        ...classNames,
      }}
      components={{
        IconLeft: () => <ChevronLeft className='h-4 w-4 rtl:rotate-180' />,
        IconRight: () => <ChevronRight className='h-4 w-4 rtl:rotate-180' />,
      }}
      {...props}
      />

Locale Options

To support and respect locale changes in the calendar component we need to pass the locale attribute to the calendar component and then we could show date numbers or month names based on the locale and change the date picker location from right-to-left 👇

TSX
...
import { arSA } from 'date-fns/locale'

function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  ...props
}: CalendarProps) {
  const NU_LOCALE = locale === arSA 
  ? 'ar-u-ca-nu-arab' 
  : 'en-u-ca-nu-latn'

  const ISLAMIC_CALENDAR = locale === arSA
   ? 'ar-u-ca-islamic-umalqura-nu-arab'
   : 'ar-u-ca-islamic-umalqura-nu-latn'

return (
  <DayPicker
      showOutsideDays={showOutsideDays}
      locale={locale}
      dir={locale === arSA ? 'rtl' : 'ltr'}
      formatters={...}
      disabled={...}
      defaultMonth={...}
      fromMonth={...}
      fromYear={...}
      toYear={...}
      className={...}
      classNames={...}
      components={...}
      {...props}
    />
  )
}

The NU_LOCALE variable is a very important variable that we are going to use in order to show the values in European Format or Hindu Arabic Format.

Also, we are going to use ISLAMIC_CALENDAR variable to show the date in Umm al-Qura Calendar. 👇

TSX
const NU_LOCALE =
    locale === arSA
      ? 'ar-u-ca-nu-arab' // Arabic-indic (٠, ١, ٢, ٣, ٤, ٥, ٦, ٧, ٨, ٩)
      : 'en-u-ca-nu-latn' // Euoropean (0, 1, 2, 3, 4, 5, 6, 7 ,8, 9)

const ISLAMIC_CALENDAR = 
	locale === arSA
	   ? 'ar-u-ca-islamic-umalqura-nu-arab' 
	   : 'ar-u-ca-islamic-umalqura-nu-latn'

IconWarning

Notice that In the ISLAMIC_CALENDAR variable, I used ar-u-ca-islamic-umalqura-nu-latn when the locale is not Arabic where I could have used en-u-ca-islamic-umalqura-nu-latn which in fact will show the hajri months names in English instead or Arabic but I avoid this because I found a bug when opening the app in mobile where it will show Gregorian month with the correct Hajri year followed by BC at the end which do not make sense at all.

I want to format two things:

  • Caption Label - Show Hajri month and Arabic-indic numerals when the locale is Arabic.
  • Day - To show Hajri day below the Gregorian.

Luckily React DayPicker give us an easy option to format a lot things inside our custom date picker using the forammter function.

Format Caption

To format the header of the calendar where the arrows and the current month and year is visible.

TSX
...
import { DayPicker, type DateFormatter } from 'react-day-picker'
import { arSA } from 'date-fns/locale'
import { addMonths } from 'date-fns'

function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  ...props
}: CalendarProps) {

  const NU_LOCALE = locale === arSA 
  ? 'ar-u-ca-nu-arab' 
  : 'en-u-ca-nu-latn'

  const ISLAMIC_CALENDAR = locale === arSA
   ? 'ar-u-ca-islamic-umalqura-nu-arab'
   : 'ar-u-ca-islamic-umalqura-nu-latn'

const formatCaption: DateFormatter = (date) => {
    const options: Intl.DateTimeFormatOptions = {
      year: 'numeric',
      month: 'long',
    }

    const dateGregorian = date.toLocaleDateString(NU_LOCALE,options)
    const dateHajri = date.toLocaleDateString(ISLAMIC_CALENDAR, options)
    const nextMonth = addMonths(date, 1)
    const dateHajriNext = nextMonth.toLocaleDateString(NU_LOCALE,options)

    return (
      <div>
        <span>{dateGregorian}</span>
        <span className='block text-[0.7rem] leading-4 text-orange-400'>
          {dateHajri} - {dateHajriNext}
        </span>
      </div>
    )
  }

return (
  <DayPicker
      showOutsideDays={showOutsideDays}
      locale={locale}
      dir={locale === arSA ? 'rtl' : 'ltr'}
      formatters={{ formatCaption, ... }}
      disabled={...}
      defaultMonth={...}
      fromMonth={...}
      fromYear={...}
      toYear={...}
      className={...}
      classNames={...}
      components={...}
      {...props}
    />
  )
}

Format Day

To format each cell showing a day in the month.

TSX
...
function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  ...props
}: CalendarProps) {

const NU_LOCALE = locale === arSA 
  ? 'ar-u-ca-nu-arab' 
  : 'en-u-ca-nu-latn'

const ISLAMIC_CALENDAR = locale === arSA
   ? 'ar-u-ca-islamic-umalqura-nu-arab'
   : 'ar-u-ca-islamic-umalqura-nu-latn'

const formatCaption: DateFormatter = (date) => {...}

const formatDay: DateFormatter = (day) => {
    const options: Intl.DateTimeFormatOptions = { day: 'numeric' }
    const dateGregorian = day.toLocaleDateString(NU_LOCALE, options)
    const dateHajri = day.toLocaleDateString(ISLAMIC_CALENDAR, options)

    return (
      <div>
        <span className='text-sm'>{dateGregorian}</span>
        <span className='block text-[0.7rem] leading-3 text-orange-400 '>
          {dateHajri}
        </span>
      </div>
    )
  }

return (
  <DayPicker
      showOutsideDays={showOutsideDays}
      locale={locale}
      dir={locale === arSA ? 'rtl' : 'ltr'}
      formatters={{ formatCaption, formatDay }}
      disabled={...}
      defaultMonth={...}
	fromMonth={...}
      fromYear={...}
      toYear={...}
      className={...}
      classNames={...}
      components={...}
      {...props}
    />
  )
}

Add Start & End Dates

I found this very helpful for me to have props I can pass to set the date in a range that starts for example in 1950 to 2030 or have default values which will set the start date to the current date and the end date to the next 50 years.

Default Variables

There is a list of Default Variables we could set to be used when no options are provided by props.

  • month - Will be used to show the current selected month.
  • setMonth - Change the current month.
  • year - This will be used to show the current selected year.
  • setYear - Change the current year.
  • isHajri - Replace the default numbering system to Hajri.
  • setIsHajri - Set the default numbering system.
  • defaultMonth - Set the default month from props or choose the current month on the month.

To control the calendar date manually we can use month prop, in order to set the date of the calendar and onMonthChange to change the date picker current display date.

TSX
...
import {useState} from 'react'

export type CalendarProps = React.ComponentProps<typeof DayPicker> & {
  start?: Date
  end?: Date
  hajri?: boolean
}

function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  start,
  end,
  hajri,
  ...props
}: CalendarProps) {

  const startDate = start ?? addDays(new Date(), -1)
  const endDate = end ?? addYears(startDate, 50)

  const [date, setDate] = useState(startDate)
  const [month, setMonth] = useState(startDate.getMonth() + 1)
  const [year, setYear] = useState(startDate.getFullYear())
  const [isHajri, setIsHajri] = useState(hajri ?? false)

  const defaultMonth =
    props.defaultMonth ??
    new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())
  
  
return (
  <DayPicker
	month={date}
    onMonthChange={setDate}
	{...}
    {...props}
    />
  )
}

Disable Dates

We could use the disabled attributes to select the range where we want to disable dates.

Using Start & End states to dates 👇

TSX
...
function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  start,
  end,
  hajri,
  ...props
}: CalendarProps) {

  const startDate = start ?? addDays(new Date(), -1)
  const endDate = end ?? addYears(startDate, 50)

  const [date, setDate] = useState(startDate)
  const [month, setMonth] = useState(startDate.getMonth() + 1)
  const [year, setYear] = useState(startDate.getFullYear())
  const [isHajri, setIsHajri] = useState(hajri ?? false)
  
  const defaultMonth =
    props.defaultMonth ??
    new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())


return (
  <DayPicker
	month={date}
      onMonthChange={setDate}
      disabled={(date: Date) => startDate > date || endDate < date}
	{...}
      {...props}
    />
  )
}

Default Month

When setting the disabled date we will have to set the defaultMonth and fromMonth in order to stop the navigation of previous inaccessible dates 👇

TSX
...
function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  start,
  end,
  hajri,
  ...props
}: CalendarProps) {

  const startDate = start ?? addDays(new Date(), -1)
  const endDate = end ?? addYears(startDate, 50)

  const [date, setDate] = useState(startDate)
  const [month, setMonth] = useState(startDate.getMonth() + 1)
  const [year, setYear] = useState(startDate.getFullYear())
  const [isHajri, setIsHajri] = useState(hajri ?? false)

  const defaultMonth =
    props.defaultMonth ??
    new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())


return (
  <DayPicker
	month={date}
      onMonthChange={setDate}
      disabled={(date: Date) => startDate > date || endDate < date}
      defaultMonth={defaultMonth}
      fromMonth={defaultMonth}
	{...}
      {...props}
    />
  )
}

Limit years selections

To limit years selection we can use the fromYear and toYear attributes to limit the selection between the start and end dates 👇

TSX
...
function Calendar({
  className,
  classNames,
  locale,
  showOutsideDays = true,
  start,
  end,
  hajri,
  ...props
}: CalendarProps) {

  const startDate = start ?? addDays(new Date(), -1)
  const endDate = end ?? addYears(startDate, 50)

  const [date, setDate] = useState(startDate)
  const [month, setMonth] = useState(startDate.getMonth() + 1)
  const [year, setYear] = useState(startDate.getFullYear())
  const [isHajri, setIsHajri] = useState(hajri ?? false)

  const defaultMonth =
    props.defaultMonth ??
    new Date(startDate.getFullYear(), startDate.getMonth(), startDate.getDate())


return (
  <DayPicker
	month={date}
      onMonthChange={setDate}
      disabled={(date: Date) => startDate > date || endDate < date}
      defaultMonth={defaultMonth}
      fromMonth={defaultMonth}
      fromYear={startDate.getFullYear()}
      toYear={endDate.getFullYear()}
	{...}
      {...props}
    />
  )
}

Change Month & Year

One of the important feature of a calendar we can add is the ability to change months or years seamlessly and to do that react-day-picker offer a dropdown option by adding captionLayout="dropdown" to display dropdown options to navigate months instead of using the next/previous arrows.

Adding the dropdown this way is good and works fine but we could make it better by creating out custom dropdown using the select component provided by shadcn/ui.

All changes will be under the formatCaption function.

Install select component

To add Shadcn/ui Select 👇

POWERSHELL
# NPM
npx shadcn-ui@latest add select

# PNPM
pnpm dlx shadcn-ui@latest add select

Helper Functions

TSX

  // Change the selected month
  function handleMonth(month: number) {
    setMonth(month)
    setDate(() => new Date(`${year.toString()}-${month}`))
  }

  // Change the selected year
  function handleYear(year: number) {
    setYear(year)
    if (
      startDate.getFullYear() === year &&
      month < defaultMonth.getMonth() + 1
    ) {
      setMonth(() => defaultMonth.getMonth() + 1)
      setDate(
        () => new Date(`${year.toString()}-${defaultMonth.getMonth() + 1}`)
      )
    } else setDate(() => new Date(`${year.toString()}-${month}`))
  }

  // To get calendar correct format.
  function getCalendar(reverse?: boolean) {
    if (reverse) return !isHajri ? ISLAMIC_CALENDAR : NU_LOCALE
    return isHajri ? ISLAMIC_CALENDAR : NU_LOCALE
  }

  // Get a list of the available years in the calendar.
  function getYears() {
    return Array.from(
      { length: endDate.getFullYear() - startDate.getFullYear() + 1 },
      (_, i) => startDate.getFullYear() + i
    )
  }

  // Get a list of values to convert into the number of months.
  function getMonths() {
    return Array.from({ length: 12 }, (_, i) => i + 1)
  }

Select Year & Month

To be able to select months we need to split the caption layout title that displays the current month and current year into two different variables:

  • dateMainYear - Get the current year (Gregorian or Hajri).
  • dateMainMonth - Get the current Month (Gregorian or Hajri)

Also, we need to convert the secondary date visible option:

  • dateSecondary - Show the opposite date calendar
  • dateNextSecondary - Show the next month of the opposite calendar.
  • dateNextMainMonth - Will be used if the Hajri Calendar is chosen to display the dateMainMonth and dateNextMinMonth since there is the default structure of the calendar is based on the Gregorian calendar
TSX

  const formatCaption: DateFormatter = (date: Date) => {
    const dateMainYear = date.toLocaleDateString(getCalendar(), {
      year: 'numeric',
    })
    const dateMainMonth = date.toLocaleDateString(getCalendar(), {
      month: 'long',
    })

    const dateSecondaryOptions: Intl.DateTimeFormatOptions = {
      year: 'numeric',
      month: 'long',
    }

    const dateSecondary = date.toLocaleDateString(
      getCalendar(true),
      dateSecondaryOptions
    )

    const nextMonth = addMonths(date, 1)

    const dateNextMainMonth = nextMonth.toLocaleDateString(getCalendar(), {
      month: 'long',
    })
    const dateNextSecondary = nextMonth.toLocaleDateString(
      getCalendar(true),
      dateSecondaryOptions
    )

    return (
      <div className='flex flex-col gap-1'>
        <div className='flex justify-between gap-5'>
          <Select
            dir={locale === arSA ? 'rtl' : 'ltr'}
            onValueChange={(val: string) => handleMonth(Number(val))}
            value={month.toString()}
          >
            <SelectTrigger className='h-full gap-2 border-none p-0 rtl:text-base'>
              <p>
                {!isHajri
                  ? dateMainMonth
                  : `${dateMainMonth} - ${dateNextMainMonth}`}
              </p>
            </SelectTrigger>
            <SelectContent>
              {getMonths().map((currMonth) => (
                <SelectItem
                  key={currMonth}
                  value={currMonth.toString()}
                  disabled={
                    isBefore(new Date(`${year}-${currMonth + 1}`), startDate) ||
                    isAfter(new Date(`${year}-${currMonth}`), endDate)
                  }
                >
                  {new Date(`${year}-${currMonth}`).toLocaleDateString(
                    getCalendar(),
                    { month: 'long' }
                  )}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>

          <Select
            dir={locale === arSA ? 'rtl' : 'ltr'}
            onValueChange={(val: string) => handleYear(Number(val))}
            value={year.toString()}
          >
            <SelectTrigger className='h-full gap-2 border-none p-0 rtl:text-base'>
              <p>{dateMainYear}</p>
            </SelectTrigger>
            <SelectContent className='max-h-72 overflow-y-auto'>
              {getYears().map((year) => (
                <SelectItem key={year} value={year.toString()}>
                  {new Date(year.toString()).toLocaleDateString(getCalendar(), {
                    year: 'numeric',
                  })}
                </SelectItem>
              ))}
            </SelectContent>
          </Select>
        </div>

        <span className='block text-[0.7rem] leading-4 text-orange-400'>
          {isHajri ? dateSecondary : `${dateSecondary} - ${dateNextSecondary}`}
        </span>
      </div>
    )
  }

How to use

Using the Calendar component is very easy and we can override the default settings as we want to match our use case.

Calendar Mods

DayPicker supports 3 built-in selection modes to display days as selected. Enable a selection mode by setting the mode prop.

  • Single mode mode="single": only a single day can be selected
  • Multiple mode mode="multiple": allow selection of multiple days
  • Range mode mode="range": allow the selection of range of days

Single mode

TSX
import {useState} from 'react'

export default function App(){
const [date, setDate] = useState<Date>()

return (
	<Calendar
	    locale={enUS} // Enable (LTR) && Euoropean Numbers 
	    mode='single' // Enable Single mode
	    selected={date}
	    onSelect={setDate}
	/>
)
}

Multiple mode

TSX
import {useState} from 'react'

export default function App(){
 const [multiple, setMultiple] = useState<Date[] | undefined>([]);
  
return (
	<Calendar
	    locale={enUS} // Enable (LTR) && Euoropean Numbers 
	    mode='multiple' // Enable Multiple mode
	    selected={multiple}
	    onSelect={setMultiple}
	/>
)
}

Range mode

TSX
import {useState} from 'react'
import { type DateRange } from 'react-day-picker'

export default function App(){
const [range, setRange] = useState<DateRange | undefined>(defaultSelected)

return (
	<Calendar
	    locale={enUS} // Enable (LTR) && Euoropean Numbers 
	    mode='range' // Enable Single mode
	    selected={range}
	    onSelect={setRange}
	/>
)
}

Other options

There are a lot of options provided by React DayPicker to change how the Calendar components work you can scroll through the documentation to learn more about all the different options.