Repository: gpbl/react-day-picker
Pull request #2647 (2024-12-26 14:38:29)

feat: add new `numerals` prop


Patch:
diff --git a/examples/NumberingSystem.tsx b/examples/NumberingSystem.tsx
deleted file mode 100644
index b6e8755dc..000000000
--- a/examples/NumberingSystem.tsx
+++ /dev/null
@@ -1,33 +0,0 @@
-import React from "react";
-
-import { format } from "date-fns";
-import { arSA } from "date-fns/locale";
-import { DayPicker, type DateLibOptions } from "react-day-picker";
-
-const NU_LOCALE = "ar-u-nu-arab";
-
-const formatDay = (day: Date) => day.getDate().toLocaleString(NU_LOCALE);
-
-const formatWeekNumber = (weekNumber: number) => {
-  return weekNumber.toLocaleString(NU_LOCALE);
-};
-
-const formatMonthCaption = (
-  date: Date,
-  options: DateLibOptions | undefined
-) => {
-  const y = date.getFullYear().toLocaleString(NU_LOCALE);
-  const m = format(date, "LLLL", { locale: options?.locale });
-  return `${m} ${y}`;
-};
-
-export function NumberingSystem() {
-  return (
-    <DayPicker
-      locale={arSA}
-      dir="rtl"
-      showWeekNumber
-      formatters={{ formatDay, formatMonthCaption, formatWeekNumber }}
-    />
-  );
-}
diff --git a/examples/Numerals.tsx b/examples/Numerals.tsx
new file mode 100644
index 000000000..5bd340efa
--- /dev/null
+++ b/examples/Numerals.tsx
@@ -0,0 +1,8 @@
+import React from "react";
+
+import { hi } from "date-fns/locale/hi";
+import { DayPicker } from "react-day-picker";
+
+export function Numerals() {
+  return <DayPicker numerals="deva" locale={hi} />;
+}
diff --git a/examples/index.ts b/examples/index.ts
index f5374abe6..82db54877 100644
--- a/examples/index.ts
+++ b/examples/index.ts
@@ -42,7 +42,7 @@ export * from "./MultipleMinMax";
 export * from "./MultipleRequired";
 export * from "./MultipleMonths";
 export * from "./MultipleMonthsPaged";
-export * from "./NumberingSystem";
+export * from "./Numerals";
 export * from "./OutsideDays";
 export * from "./PastDatesDisabled";
 export * from "./Persian";
diff --git a/src/DayPicker.tsx b/src/DayPicker.tsx
index aaface07e..c581c2ffc 100644
--- a/src/DayPicker.tsx
+++ b/src/DayPicker.tsx
@@ -48,7 +48,8 @@ export function DayPicker(props: DayPickerProps) {
           firstWeekContainsDate: props.firstWeekContainsDate,
           useAdditionalWeekYearTokens: props.useAdditionalWeekYearTokens,
           useAdditionalDayOfYearTokens: props.useAdditionalDayOfYearTokens,
-          timeZone: props.timeZone
+          timeZone: props.timeZone,
+          numerals: props.numerals
         },
         props.dateLib
       );
@@ -69,6 +70,7 @@ export function DayPicker(props: DayPickerProps) {
       props.useAdditionalWeekYearTokens,
       props.useAdditionalDayOfYearTokens,
       props.timeZone,
+      props.numerals,
       props.dateLib,
       props.components,
       props.formatters,
diff --git a/src/classes/DateLib.ts b/src/classes/DateLib.ts
index 752450a05..37b6709a6 100644
--- a/src/classes/DateLib.ts
+++ b/src/classes/DateLib.ts
@@ -44,6 +44,7 @@ import { enUS } from "date-fns/locale";
 import { endOfBroadcastWeek } from "../helpers/endOfBroadcastWeek.js";
 import { startOfBroadcastWeek } from "../helpers/startOfBroadcastWeek.js";
 import type { PropsBase } from "../types/props.js";
+import { Numerals } from "../types/shared.js";
 
 export type { Locale } from "date-fns/locale";
 export type { Month as DateFnsMonth } from "date-fns";
@@ -76,8 +77,18 @@ export interface DateLibOptions
   Date?: typeof Date;
   /** A locale to use for formatting dates. */
   locale?: Locale;
-  /** A time zone to use for dates. */
+  /**
+   * A time zone to use for dates.
+   *
+   * @since 9.5.0
+   */
   timeZone?: string;
+  /**
+   * The numbering system to use for formatting numbers.
+   *
+   * @since 9.5.0
+   */
+  numerals?: Numerals;
 }
 
 /**
@@ -111,6 +122,49 @@ export class DateLib {
     this.overrides = overrides;
   }
 
+  /**
+   * Generate digit map dynamically using Intl.NumberFormat.
+   *
+   * @since 9.5.0
+   */
+  private getDigitMap(): Record<string, string> {
+    const { numerals = "latn" } = this.options;
+
+    // Use Intl.NumberFormat to create a formatter with the specified numbering system
+    const formatter = new Intl.NumberFormat("en-US", {
+      numberingSystem: numerals
+    });
+
+    // Map Arabic digits (0-9) to the target numerals
+    const digitMap: Record<string, string> = {};
+    for (let i = 0; i < 10; i++) {
+      digitMap[i.toString()] = formatter.format(i);
+    }
+
+    return digitMap;
+  }
+
+  /**
+   * Replace Arabic digits with the target numbering system digits.
+   *
+   * @since 9.5.0
+   */
+  private replaceDigits(input: string): string {
+    const digitMap = this.getDigitMap();
+    return input.replace(/\d/g, (digit) => digitMap[digit] || digit);
+  }
+
+  /**
+   * Format number using the custom numbering system.
+   *
+   * @since 9.5.0
+   * @param value The number to format.
+   * @returns The formatted number.
+   */
+  formatNumber(value: number): string {
+    return this.replaceDigits(value.toString());
+  }
+
   /**
    * Reference to the built-in Date constructor.
    *
@@ -324,9 +378,13 @@ export class DateLib {
    * @returns The formatted date string.
    */
   format: typeof format = (date, formatStr) => {
-    return this.overrides?.format
+    const formatted = this.overrides?.format
       ? this.overrides.format(date, formatStr, this.options)
       : format(date, formatStr, this.options);
+    if (this.options.numerals && this.options.numerals !== "latn") {
+      return this.replaceDigits(formatted);
+    }
+    return formatted;
   };
 
   /**
diff --git a/src/types/props.ts b/src/types/props.ts
index ad0df82e4..cd93523ad 100644
--- a/src/types/props.ts
+++ b/src/types/props.ts
@@ -16,7 +16,8 @@ import type {
   DayEventHandler,
   Modifiers,
   DateRange,
-  Mode
+  Mode,
+  Numerals
 } from "./shared.js";
 
 /**
@@ -390,6 +391,12 @@ export interface PropsBase {
    * @see https://github.com/date-fns/date-fns/tree/main/src/locale for a list of the supported locales
    */
   locale?: Partial<Locale> | undefined;
+  /**
+   * The numeral system to use when formatting dates.
+   *
+   * @see https://daypicker.dev/docs/translation#numeral-systems
+   */
+  numerals?: Numerals;
   /**
    * The index of the first day of the week (0 - Sunday). Overrides the locale's
    * one.
diff --git a/src/types/shared.ts b/src/types/shared.ts
index 935d4e0c8..be3cae5e5 100644
--- a/src/types/shared.ts
+++ b/src/types/shared.ts
@@ -408,3 +408,35 @@ export type MoveFocusBy =
   | "endOfWeek"
   | "month"
   | "year";
+
+/**
+ * The numbering system supported by DayPicker.
+ *
+ * - `latn`: Latin (Western Arabic)
+ * - `arab`: Arabic-Indic
+ * - `arabext`: Eastern Arabic-Indic (Persian)
+ * - `deva`: Devanagari
+ * - `beng`: Bengali
+ * - `guru`: Gurmukhi
+ * - `gujr`: Gujarati
+ * - `orya`: Oriya
+ * - `tamldec`: Tamil
+ * - `telu`: Telugu
+ * - `knda`: Kannada
+ * - `mlym`: Malayalam
+ *
+ * @see https://daypicker.dev/docs/translation#numeral-systems
+ */
+export type Numerals =
+  | "latn"
+  | "arab"
+  | "arabext"
+  | "deva"
+  | "beng"
+  | "guru"
+  | "gujr"
+  | "orya"
+  | "tamldec"
+  | "telu"
+  | "knda"
+  | "mlym";
diff --git a/website/docs/docs/translation.mdx b/website/docs/docs/translation.mdx
index e1d605e4e..5a863c3ae 100644
--- a/website/docs/docs/translation.mdx
+++ b/website/docs/docs/translation.mdx
@@ -97,6 +97,24 @@ import { arSA } from "react-day-picker/locale";
   <Examples.Rtl />
 </BrowserWindow>
 
+## Numeral Systems
+
+| Prop Name  | Type                                          | Description             |
+| ---------- | --------------------------------------------- | ----------------------- |
+| `numerals` | [`Numerals`](../api/type-aliases/Numerals.md) | Set the numeral system. |
+
+Use the `numerals` prop to set the numeral system. The default is `latn`.
+
+```tsx
+import { hi } from "date-fns/locale/hi";
+
+<DayPicker numerals="deva" locale={hi} />;
+```
+
+<BrowserWindow>
+  <Examples.Numerals />
+</BrowserWindow>
+
 ## Custom Formatters
 
 Use the `formatters` prop to customize the formatting of dates, week numbers, day names, and more.
@@ -120,43 +138,3 @@ import { format } from "date-fns";
 | [`formatWeekNumberHeader`](../api/functions/formatWeekNumberHeader.md) | Format the week number header.                                  |
 | [`formatWeekdayName`](../api/functions/formatWeekdayName.md)           | Format the weekday name to be displayed in the weekdays header. |
 | [`formatYearDropdown`](../api/functions/formatYearDropdown.md)         | Format the years for the dropdown option label.                 |
-
-### Numbering System
-
-Use the `formatters` prop to change the [numbering system](https://en.wikipedia.org/wiki/Numeral_system) used in the calendar.
-
-For example, to switch to Hindu-Arabic numerals, use the native [`Date.toLocaleString`](https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/Date/toLocaleString) method:
-
-```tsx
-import { format } from "date-fns/format";
-import { DayPicker, Formatters } from "react-day-picker";
-import { arSA } from "react-day-picker/locale";
-
-const NU_LOCALE = "ar-u-nu-arab";
-
-const formatDay = (day) => day.getDate().toLocaleString(NU_LOCALE);
-const formatWeekNumber = (weekNumber) => weekNumber.toLocaleString(NU_LOCALE);
-const formatMonthCaption = (date, options) => {
-  const y = date.getFullYear().toLocaleString(NU_LOCALE);
-  const m = format(date, "LLLL", options);
-  return `${m} ${y}`;
-};
-
-export function NumberingSystemExample() {
-  return (
-    <DayPicker
-      locale={arSA}
-      dir="rtl"
-      formatters={{
-        formatDay,
-        formatMonthCaption,
-        formatWeekNumber
-      }}
-    />
-  );
-}
-```
-
-<BrowserWindow>
-  <Examples.NumberingSystem />
-</BrowserWindow>
diff --git a/website/src/pages/playground.tsx b/website/src/pages/playground.tsx
index 32b564ff0..fa543da08 100644
--- a/website/src/pages/playground.tsx
+++ b/website/src/pages/playground.tsx
@@ -7,7 +7,8 @@ import {
   type DayPickerProps,
   DateLib,
   DayPicker,
-  isDateRange
+  isDateRange,
+  Numerals
 } from "react-day-picker";
 import * as locales from "react-day-picker/locale";
 import {
@@ -32,6 +33,21 @@ const timeZones = [
 
 const calendars = ["Gregorian", "Persian"];
 const persianLocales = { faIR: faIRPersian, enUS: enUSPersian };
+
+const numerals: { value: Numerals; label: string }[] = [
+  { value: "latn", label: "Latin (Western Arabic)" },
+  { value: "arab", label: "Arabic-Indic" },
+  { value: "arabext", label: "Eastern Arabic-Indic (Persian)" },
+  { value: "deva", label: "Devanagari" },
+  { value: "beng", label: "Bengali" },
+  { value: "guru", label: "Gurmukhi" },
+  { value: "gujr", label: "Gujarati" },
+  { value: "orya", label: "Oriya" },
+  { value: "tamldec", label: "Tamil" },
+  { value: "telu", label: "Telugu" },
+  { value: "knda", label: "Kannada" },
+  { value: "mlym", label: "Malayalam" }
+];
 /**
  * Function to format a json object of props to a jsx source displaying the
  * props as example
@@ -421,6 +437,33 @@ export default function Playground() {
                   ))}
                 </select>
               </label>
+              <label>
+                Numerals:
+                <select
+                  style={{ maxWidth: 100 }}
+                  name="numerals"
+                  value={
+                    numerals.find((numeral) => numeral.value === props.numerals)
+                      ?.value
+                  }
+                  onChange={(e) =>
+                    setProps({
+                      ...props,
+                      numerals:
+                        e.target.value === ""
+                          ? undefined
+                          : (e.target.value as Numerals)
+                    })
+                  }
+                >
+                  <option value=""></option>
+                  {numerals.map((numeral) => (
+                    <option key={numeral.value} value={numeral.value}>
+                      {numeral.label}
+                    </option>
+                  ))}
+                </select>
+              </label>
               <label>
                 Locale:
                 {calendar === "Persian" ? (


Test patch:
diff --git a/examples/NumberingSystem.test.tsx b/examples/NumberingSystem.test.tsx
deleted file mode 100644
index 221259ebd..000000000
--- a/examples/NumberingSystem.test.tsx
+++ /dev/null
@@ -1,21 +0,0 @@
-import React from "react";
-
-import { render, screen } from "@/test/render";
-
-import { NumberingSystem } from "./NumberingSystem";
-
-const today = new Date(2024, 8, 19);
-
-beforeAll(() => jest.setSystemTime(today));
-afterAll(() => jest.useRealTimers());
-
-beforeEach(() => {
-  render(<NumberingSystem />);
-});
-
-test("should localize the days", () => {
-  expect(screen.getByText("أربعاء")).toBeInTheDocument();
-});
-test("should localize the week numbers", () => {
-  expect(screen.getByText("١٤")).toBeInTheDocument();
-});
diff --git a/examples/Numerals.test.tsx b/examples/Numerals.test.tsx
new file mode 100644
index 000000000..91809959b
--- /dev/null
+++ b/examples/Numerals.test.tsx
@@ -0,0 +1,18 @@
+import React from "react";
+
+import { render } from "@testing-library/react";
+
+import { grid } from "@/test/elements";
+
+import { Numerals } from "./Numerals";
+
+const today = new Date(2025, 10, 25);
+
+beforeAll(() => jest.setSystemTime(today));
+afterAll(() => jest.useRealTimers());
+
+test("should use Devanagari numerals", () => {
+  render(<Numerals />);
+
+  expect(grid()).toHaveTextContent("२५");
+});