From 85955da15c652e1e2bfad85cb40d49915263717d Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Fri, 20 Mar 2026 20:03:46 -0400 Subject: [PATCH 01/12] wip add rendering of day picker from reason native --- .gitignore | 1 + README.md | 237 +++++++- ReactDayPicker.re | 146 +++-- ReactDayPickerNative.re | 954 +++++++++++++++++++++++++++++++ dune | 15 +- dune-project | 29 +- example/README.md | 53 ++ example/check-parity.sh | 48 ++ example/js/JsRenderer.re | 56 ++ example/js/dune | 9 + example/native/NativeRenderer.re | 49 ++ example/native/dune | 9 + example/shared/Scenario.re | 97 ++++ example/shared/SharedFixture.re | 9 + 14 files changed, 1613 insertions(+), 99 deletions(-) create mode 100644 ReactDayPickerNative.re create mode 100644 example/README.md create mode 100755 example/check-parity.sh create mode 100644 example/js/JsRenderer.re create mode 100644 example/js/dune create mode 100644 example/native/NativeRenderer.re create mode 100644 example/native/dune create mode 100644 example/shared/Scenario.re create mode 100644 example/shared/SharedFixture.re diff --git a/.gitignore b/.gitignore index 85c33a6..d49a0f0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ +node_modules lib *.annot *.cmo diff --git a/README.md b/README.md index 06ba33f..4cd2160 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,9 @@ ## Note These are the first bindings I made for a React component. Needs improvement. Feedback and suggestions are highly welcome. -## Usage +## Installation + +### Melange (Browser/Client) Add as OPAM dependency using pin: @@ -9,38 +11,215 @@ Add as OPAM dependency using pin: Add `reason-react-day-picker` to `libraries` dune stanza: -``` +```lisp (libraries reason-react-day-picker) ``` +### Native (Server-Side Rendering) + +The native version is optional and only builds when `server-reason-react` is installed. + +1. Install server-reason-react: +```bash +opam install server-reason-react +``` + +2. Add `reason-react-day-picker.native` to your libraries: +```lisp +(libraries + reason-react-day-picker.native) +``` + +## Usage + +### Melange (Browser) + ```ocaml - { - switch (dates->Js.Undefined.toOption) { - | Some(dates) => - let openDate = - switch (dates.from->Js.Undefined.toOption) { - | Some(date) => date - | None => today +let props = + ReactDayPicker.makeProps( + ~mode=`Range, + ~selected=`Range(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(openDate), + ReactDayPicker.to_: Js.Undefined.return(closeDate), + })), + ~onSelect=`Range((dates: ReactDayPicker.rangeDate) => { + switch (dates->Js.Undefined.toOption) { + | Some(dates) => + let openDate = + switch (dates.from->Js.Undefined.toOption) { + | Some(date) => date + | None => today + }; + let closeDate = + switch (dates.to_->Js.Undefined.toOption) { + | Some(date) => date + | None => openDate + }; + updateOpenDate(openDate); + updateCloseDate(closeDate); + | None => + updateOpenDate(today); + updateCloseDate(today); }; - let closeDate = - switch (dates.to_->Js.Undefined.toOption) { - | Some(date) => date - | None => openDate - }; - updateOpenDate(openDate); - updateCloseDate(closeDate); - | None => { - updateOpenDate(today); - updateCloseDate(today); - } - }; -})} -/> + }), + (), + ); + +let calendar = React.createElement(ReactDayPicker.make, props); +``` + +### Native (Server-Side Rendering) + +```reason +let today = Js.Date.make(); + +let calendar = + ReactDayPickerNative.make( + ~mode=`Single, + ~selected=`Single(Some(today)), + ~numberOfMonths=1, + ~showOutsideDays=true, + (), + ); + +/* Render to HTML string */ +let html = ReactDOM.renderToString(calendar); +``` + +### Example parity check + +This repo includes a small universal example in `example/` that mirrors the +layout used by `server-reason-react` demos: + +```bash +dune exec ./example/native/NativeRenderer.exe +dune build example/js/render/example/js/JsRenderer.re.js +node _build/default/example/js/render/example/js/JsRenderer.re.js +``` + +The Melange renderer needs `react`, `react-dom`, and `react-day-picker` +installed in `node_modules` to run under Node. + +#### Native Props + +The native implementation supports the following props: + +| Prop | Type | Description | +|------|------|-------------| +| `mode` | `option(mode)` | Selection mode using `` `Single ``, `` `Multiple ``, or `` `Range `` | +| `selected` | `option(selected)` | Selected date(s) | +| `captionLayout` | `option(captionLayout)` | Caption layout using typed constructors | +| `navLayout` | `option(navLayout)` | Navigation layout using typed constructors | +| `numberOfMonths` | `option(int)` | Number of months to display (default: 1) | +| `showOutsideDays` | `option(bool)` | Show days from previous/next months (default: false) | +| `showWeekNumber` | `option(bool)` | Show week numbers (default: false) | +| `hideWeekdays` | `option(bool)` | Hide weekday header row (default: false) | +| `footer` | `option(React.element)` | Footer content | + +#### Selection Types + +```reason +type mode = [ + | `Single + | `Multiple + | `Range +]; + +type captionLayout = [ + | `Label + | `Dropdown + | `DropdownMonths + | `DropdownYears +]; + +type navLayout = [ + | `Around + | `After +]; + +type selected = [ + | `Single(Js.Date.t option) + | `Multiple(array(Js.Date.t) option) + | `Range(dateRange option) +]; + +type dateRange = { + from: Js.Date.t option, + to_: Js.Date.t option, +}; + +type reactNode = React.element; ``` + +For text footers, pass `React.string("...")`. + +#### Styling + +The native output uses the same CSS classes as react-day-picker. Include the CSS from the NPM package or use your own styles: + +```css +/* Include react-day-picker styles */ +@import "react-day-picker/style.css"; +``` + +Or use the `rdp-*` class names to create custom styles. + +## HTML Output Structure + +The native component renders this HTML structure: + +```html +
+
+
+
+ January 2026 +
+ + + + + + + + + + + + + + +
Su
2815
+
+
+ +
+``` + +## CSS Classes + +| Class | Description | +|-------|-------------| +| `rdp-root` | Root container | +| `rdp-months` | Months wrapper | +| `rdp-month` | Single month container | +| `rdp-month_caption` | Month caption (title) | +| `rdp-caption_label` | Month/year label | +| `rdp-month_grid` | Calendar table | +| `rdp-weekday` | Weekday header cell | +| `rdp-weeks` | Table body | +| `rdp-week` | Week row | +| `rdp-day` | Day cell | +| `rdp-today` | Today's date | +| `rdp-selected` | Selected date | +| `rdp-outside` | Day outside current month | +| `rdp-range_start` | Range selection start | +| `rdp-range_middle` | Range selection middle | +| `rdp-range_end` | Range selection end | +| `rdp-week_number` | Week number cell | +| `rdp-footer` | Footer container | + +## License + +MIT diff --git a/ReactDayPicker.re b/ReactDayPicker.re index fc83601..6f8bf7e 100644 --- a/ReactDayPicker.re +++ b/ReactDayPicker.re @@ -1,45 +1,75 @@ -type captionLayout = - | Label - | Dropdown - | DropdownMonths - | DropdownYears; +type mode = [ + | `Single + | `Multiple + | `Range +]; + +let modeToString = (v: mode): string => + switch (v) { + | `Single => "single" + | `Multiple => "multiple" + | `Range => "range" + }; + +type captionLayout = [ + | `Label + | `Dropdown + | `DropdownMonths + | `DropdownYears +]; let captionLayoutToString = (v: captionLayout): string => switch (v) { - | Label => "label" - | Dropdown => "dropdown" - | DropdownMonths => "dropdown-months" - | DropdownYears => "dropdown-years" + | `Label => "label" + | `Dropdown => "dropdown" + | `DropdownMonths => "dropdown-months" + | `DropdownYears => "dropdown-years" }; -type navLayout = - | Around - | After; +type navLayout = [ + | `Around + | `After +]; let navLayoutToString = (v: navLayout): string => switch (v) { - | Around => "around" - | After => "after" + | `Around => "around" + | `After => "after" }; -/* `footer` can be a string or a React node. */ -type footer = - | FooterString(string) - | FooterNode(React.element); +type reactNode = React.element; + +/* `reactNode` can be created with `React.string`, `React.int`, JSX, etc. */ type singleDate = Js.Undefined.t(Js.Date.t); type multipleDate = Js.Undefined.t(array(Js.Date.t)); type dateRange = { from: singleDate, - [@mel.as "to"] - to_: singleDate, + [@mel.as "to"] to_: singleDate, }; type rangeDate = Js.Undefined.t(dateRange); +type selected = [ + | `Single(singleDate) + | `Multiple(multipleDate) + | `Range(rangeDate) +]; + +type onSelect = [ + | `Single(singleDate => unit) + | `Multiple(multipleDate => unit) + | `Range(rangeDate => unit) +]; + [@mel.obj] external makeProps: ( - ~mode: 'mode, + ~mode: + [@mel.string] [ + | [@mel.as "single"] `Single + | [@mel.as "multiple"] `Multiple + | [@mel.as "range"] `Range + ], ~onSelect: [@mel.unwrap] [ | `Single(singleDate => unit) @@ -52,42 +82,52 @@ external makeProps: | `Multiple(multipleDate) | `Range(rangeDate) ], - ~captionLayout: 'captionLayout=?, - ~reverseYears: 'reverseYears=?, - ~navLayout: 'navLayout=?, - ~disableNavigation: 'disableNavigation=?, - ~hideNavigation: 'hideNavigation=?, - ~animate: 'animate=?, - ~fixedWeeks: 'fixedWeeks=?, - ~footer: 'footer=?, - ~hideWeekdays: 'hideWeedays=?, - ~numberOfMonths: 'numberOfMonths=?, - ~reverseMonths: 'reverseMonths=?, - ~pagatedNavigation: 'pagedNavigation=?, - ~showOutsideDays: 'showOutsideDays=?, - ~showWeekNumber: 'showWeekNumber=?, + ~captionLayout: + [@mel.string] [ + | [@mel.as "label"] `Label + | [@mel.as "dropdown"] `Dropdown + | [@mel.as "dropdown-months"] `DropdownMonths + | [@mel.as "dropdown-years"] `DropdownYears + ]=?, + ~reverseYears: bool=?, + ~navLayout: + [@mel.string] [ + | [@mel.as "around"] `Around + | [@mel.as "after"] `After + ]=?, + ~disableNavigation: bool=?, + ~hideNavigation: bool=?, + ~animate: bool=?, + ~fixedWeeks: bool=?, + ~footer: reactNode=?, + ~hideWeekdays: bool=?, + ~numberOfMonths: int=?, + ~reverseMonths: bool=?, + ~pagedNavigation: bool=?, + ~showOutsideDays: bool=?, + ~showWeekNumber: bool=?, ~key: string=?, unit ) => { . - "mode": 'mode, + "mode": string, "selected": 'selected, "onSelect": 'onSelect, - "captionLayout": 'captionLayout, - "reverseYears": 'reverseYears, - "navLayout": 'navLayout, - "disableNavigation": 'disableNavigation, - "hideNavigation": 'hideNavigation, - "animate": 'animate, - "fixedWeeks": 'fixedWeeks, - "footer": 'footer, - "hideWeekdays": 'hideWeekdays, - "numberOfMonths": 'numberOfMonths, - "reverseMonths": 'reverseMonths, - "pagedNavigation": 'pagedNavigation, - "showOutsideDays": 'showOutsideDays, - "showWeekNumber": 'showWeekNumber, + "captionLayout": string, + "reverseYears": bool, + "navLayout": string, + "disableNavigation": bool, + "hideNavigation": bool, + "animate": bool, + "fixedWeeks": bool, + "footer": reactNode, + "hideWeekdays": bool, + "numberOfMonths": int, + "reverseMonths": bool, + "pagedNavigation": bool, + "showOutsideDays": bool, + "showWeekNumber": bool, }; [@mel.module "react-day-picker"] @@ -97,13 +137,13 @@ external make: "mode": string, "onSelect": 'onSelect, "selected": 'selected, - "captionLayout": captionLayout, - "navLayout": navLayout, + "captionLayout": string, + "navLayout": string, "disableNavigation": bool, "hideNavigation": bool, "animate": bool, "fixedWeeks": bool, - "footer": footer, + "footer": reactNode, "hideWeekdays": bool, "numberOfMonths": int, "reverseMonths": bool, diff --git a/ReactDayPickerNative.re b/ReactDayPickerNative.re new file mode 100644 index 0000000..785c74b --- /dev/null +++ b/ReactDayPickerNative.re @@ -0,0 +1,954 @@ +type mode = [ + | `Single + | `Multiple + | `Range +]; + +let modeToString = (v: mode): string => + switch (v) { + | `Single => "single" + | `Multiple => "multiple" + | `Range => "range" + }; + +type captionLayout = [ + | `Label + | `Dropdown + | `DropdownMonths + | `DropdownYears +]; + +let captionLayoutToString = (v: captionLayout): string => + switch (v) { + | `Label => "label" + | `Dropdown => "dropdown" + | `DropdownMonths => "dropdown-months" + | `DropdownYears => "dropdown-years" + }; + +type navLayout = [ + | `Around + | `After +]; + +let navLayoutToString = (v: navLayout): string => + switch (v) { + | `Around => "around" + | `After => "after" + }; + +type reactNode = React.element; + +type singleDate = Js.Undefined.t(Js.Date.t); +type multipleDate = Js.Undefined.t(array(Js.Date.t)); + +type dateRange = { + from: singleDate, + to_: singleDate, +}; + +type rangeDate = Js.Undefined.t(dateRange); + +type selected = [ + | `Single(singleDate) + | `Multiple(multipleDate) + | `Range(rangeDate) +]; + +type onSelect = [ + | `Single(singleDate => unit) + | `Multiple(multipleDate => unit) + | `Range(rangeDate => unit) +]; + +type classNames = { + root: string, + months: string, + nav: string, + buttonPrevious: string, + buttonNext: string, + chevron: string, + month: string, + monthCaption: string, + dropdowns: string, + dropdownRoot: string, + dropdown: string, + monthsDropdown: string, + yearsDropdown: string, + captionLabel: string, + monthGrid: string, + weekdays: string, + weekday: string, + weekNumberHeader: string, + weeks: string, + week: string, + weekNumber: string, + day: string, + dayButton: string, + footer: string, + dayToday: string, + daySelected: string, + dayOutside: string, + dayHidden: string, + dayRangeStart: string, + dayRangeMiddle: string, + dayRangeEnd: string, +}; + +let classNames: classNames = { + root: "rdp-root", + months: "rdp-months", + nav: "rdp-nav", + buttonPrevious: "rdp-button_previous", + buttonNext: "rdp-button_next", + chevron: "rdp-chevron", + month: "rdp-month", + monthCaption: "rdp-month_caption", + dropdowns: "rdp-dropdowns", + dropdownRoot: "rdp-dropdown_root", + dropdown: "rdp-dropdown", + monthsDropdown: "rdp-months_dropdown", + yearsDropdown: "rdp-years_dropdown", + captionLabel: "rdp-caption_label", + monthGrid: "rdp-month_grid", + weekdays: "rdp-weekdays", + weekday: "rdp-weekday", + weekNumberHeader: "rdp-week_number_header", + weeks: "rdp-weeks", + week: "rdp-week", + weekNumber: "rdp-week_number", + day: "rdp-day", + dayButton: "rdp-day_button", + footer: "rdp-footer", + dayToday: "rdp-today", + daySelected: "rdp-selected", + dayOutside: "rdp-outside", + dayHidden: "rdp-hidden", + dayRangeStart: "rdp-range_start", + dayRangeMiddle: "rdp-range_middle", + dayRangeEnd: "rdp-range_end", +}; + +type calendarDay = { + date: Js.Date.t, + isOutside: bool, +}; + +type calendarWeek = { + days: array(calendarDay), + weekNumber: int, +}; + +let dateYear = (date: Js.Date.t): int => int_of_float(Js.Date.getFullYear(date)); +let dateMonth = (date: Js.Date.t): int => int_of_float(Js.Date.getMonth(date)); +let dateDay = (date: Js.Date.t): int => int_of_float(Js.Date.getDate(date)); +let dayOfWeek = (date: Js.Date.t): int => int_of_float(Js.Date.getDay(date)); + +let monthNames = [|"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"|]; + +let getMonthName = (index: int): string => + if (index >= 0 && index < Array.length(monthNames)) { + Array.get(monthNames, index); + } else { + ""; + }; + +let newDate = (~year: int, ~month: int, ~day: int): Js.Date.t => { + Js.Date.makeWithYMDHMS( + ~year=float_of_int(year), + ~month=float_of_int(month), + ~date=float_of_int(day), + ~hours=12., + ~minutes=0., + ~seconds=0., + ); +}; + +let setMonth = (date: Js.Date.t, month: int): Js.Date.t => { + newDate(~year=dateYear(date), ~month, ~day=dateDay(date)); +}; + +let startOfMonth = (date: Js.Date.t): Js.Date.t => newDate(~year=dateYear(date), ~month=dateMonth(date), ~day=1); + +let isBefore = (a: Js.Date.t, b: Js.Date.t): bool => Js.Date.getTime(a) < Js.Date.getTime(b); +let isAfter = (a: Js.Date.t, b: Js.Date.t): bool => Js.Date.getTime(a) > Js.Date.getTime(b); + +let isSameDay = (a: Js.Date.t, b: Js.Date.t): bool => { + dateYear(a) == dateYear(b) && dateMonth(a) == dateMonth(b) && dateDay(a) == dateDay(b); +}; + +let daysInMonth = (year: int, month: int): int => { + switch (month mod 12) { + | 0 => 31 + | 1 => + if (year mod 4 == 0 && (year mod 100 != 0 || year mod 400 == 0)) { + 29; + } else { + 28; + } + | 2 => 31 + | 3 => 30 + | 4 => 31 + | 5 => 30 + | 6 => 31 + | 7 => 31 + | 8 => 30 + | 9 => 31 + | 10 => 30 + | 11 => 31 + | _ => 30 + }; +}; + +let formatMonthYear = (date: Js.Date.t): string => { + let monthName = + if (dateMonth(date) >= 0 && dateMonth(date) < Array.length(monthNames)) { + Some(Array.get(monthNames, dateMonth(date))); + } else { + None; + }; + switch (monthName) { + | Some(value) => value ++ " " ++ string_of_int(dateYear(date)) + | None => string_of_int(dateYear(date)) + }; +}; + +let formatDayNumber = (date: Js.Date.t): string => string_of_int(dateDay(date)); + +let formatISODate = (date: Js.Date.t): string => { + let y = dateYear(date); + let m = dateMonth(date) + 1; + let d = dateDay(date); + let mm = if (m < 10) { "0" ++ string_of_int(m) } else { string_of_int(m) }; + let dd = if (d < 10) { "0" ++ string_of_int(d) } else { string_of_int(d) }; + string_of_int(y) ++ "-" ++ mm ++ "-" ++ dd; +}; + +let formatYearMonth = (date: Js.Date.t): string => { + let y = dateYear(date); + let m = dateMonth(date) + 1; + let mm = if (m < 10) { "0" ++ string_of_int(m) } else { string_of_int(m) }; + string_of_int(y) ++ "-" ++ mm; +}; + +let weekdayShort = [|"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"|]; +let weekdayLabel = [|"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"|]; +let getWeekdayName = (index: int): string => + if (index >= 0 && index < Array.length(weekdayLabel)) { + Array.get(weekdayLabel, index); + } else { + ""; + }; +let getWeekdayShort = (index: int): string => + if (index >= 0 && index < Array.length(weekdayShort)) { + Array.get(weekdayShort, index); + } else { + ""; + }; + +let getDayOfYear = (date: Js.Date.t): int => { + let yearVal = dateYear(date); + let monthVal = dateMonth(date); + let dayVal = dateDay(date); + let accumulator = ref(0); + for (monthIndex in 0 to monthVal - 1) { + accumulator := accumulator^ + daysInMonth(yearVal, monthIndex); + }; + accumulator^ + dayVal; +}; + +let getWeekNumber = (date: Js.Date.t): int => { + let yearVal = dateYear(date); + let firstOfYear = newDate(~year=yearVal, ~month=0, ~day=1); + let firstWeekday = dayOfWeek(firstOfYear); + ((getDayOfYear(date) - 1 + firstWeekday) / 7) + 1; +}; + +let buildMonthWeeks = (monthDate: Js.Date.t, ~weekStartsOn: int=0, ~fixedWeeks: bool=false, ()): array(calendarWeek) => { + let yearVal = dateYear(monthDate); + let monthVal = dateMonth(monthDate); + let firstDayOfMonth = newDate(~year=yearVal, ~month=monthVal, ~day=1); + let lastDay = daysInMonth(yearVal, monthVal); + let firstDayWeekday = (dayOfWeek(firstDayOfMonth) - weekStartsOn + 7) mod 7; + let totalWeeks = + if (fixedWeeks) { + 6; + } else { + (firstDayWeekday + lastDay + 6) / 7; + }; + + let prevMonth = if (monthVal == 0) { 11 } else { monthVal - 1 }; + let prevYear = if (monthVal == 0) { yearVal - 1 } else { yearVal }; + let prevMonthDays = daysInMonth(prevYear, prevMonth); + + let weeks = ref([]); + let dayCounter = ref(1); + let nextMonthDay = ref(1); + + for (weekIndex in 0 to totalWeeks - 1) { + let weekDays = ref([]); + for (dayIndex in 0 to 6) { + let dayInfo = + if (weekIndex == 0 && dayIndex < firstDayWeekday) { + let prevDay = prevMonthDays - firstDayWeekday + dayIndex + 1; + {date: newDate(~year=prevYear, ~month=prevMonth, ~day=prevDay), isOutside: true}; + } else if (dayCounter^ > lastDay) { + let nextMonth = if (monthVal == 11) { 0 } else { monthVal + 1 }; + let nextYear = if (monthVal == 11) { yearVal + 1 } else { yearVal }; + let dayObj = newDate(~year=nextYear, ~month=nextMonth, ~day=nextMonthDay^); + nextMonthDay := nextMonthDay^ + 1; + {date: dayObj, isOutside: true}; + } else { + let dayObj = newDate(~year=yearVal, ~month=monthVal, ~day=dayCounter^); + dayCounter := dayCounter^ + 1; + {date: dayObj, isOutside: false}; + }; + weekDays := [dayInfo, ...weekDays^]; + }; + + let reversedWeekDays = List.rev(weekDays^); + let weekDaysArray = Array.of_list(reversedWeekDays); + let firstDate = + switch (reversedWeekDays) { + | [value, ..._] => value + | [] => {date: newDate(~year=yearVal, ~month=monthVal, ~day=1), isOutside: false} + }; + let weekNum = getWeekNumber(firstDate.date); + + weeks := [{days: weekDaysArray, weekNumber: weekNum}, ...weeks^]; + }; + + Array.of_list(List.rev(weeks^)); +}; + +let isDateSelected = (date: Js.Date.t, selected: option(selected)): bool => { + switch (selected) { + | None => false + | Some(`Single(optionDate)) => + switch (Js.Undefined.toOption(optionDate)) { + | Some(value) => isSameDay(date, value) + | None => false + } + | Some(`Multiple(optionDates)) => + switch (Js.Undefined.toOption(optionDates)) { + | Some(values) => values |> Array.exists((value) => isSameDay(date, value)) + | None => false + } + | Some(`Range(optionRange)) => + switch (Js.Undefined.toOption(optionRange)) { + | None => false + | Some(rangeValue) => + switch (Js.Undefined.toOption(rangeValue.from), Js.Undefined.toOption(rangeValue.to_)) { + | (Some(from), Some(to_)) => + (isAfter(date, from) || isSameDay(date, from)) && (isBefore(date, to_) || isSameDay(date, to_)) + | (Some(from), None) => isSameDay(date, from) + | (None, Some(to_)) => isSameDay(date, to_) + | (None, None) => false + } + } + }; +}; + +type rangePosition = + | NoRange + | RangeStart + | RangeMiddle + | RangeEnd; + +let getRangePosition = (date: Js.Date.t, selected: option(selected)): rangePosition => { + switch (selected) { + | Some(`Range(optionRange)) => + switch (Js.Undefined.toOption(optionRange)) { + | Some(rangeValue) => + let isStart = + switch (Js.Undefined.toOption(rangeValue.from)) { + | Some(value) => isSameDay(date, value) + | None => false + }; + let isEnd = + switch (Js.Undefined.toOption(rangeValue.to_)) { + | Some(value) => isSameDay(date, value) + | None => false + }; + if (isStart) { + RangeStart; + } else if (isEnd) { + RangeEnd; + } else { + switch (Js.Undefined.toOption(rangeValue.from), Js.Undefined.toOption(rangeValue.to_)) { + | (Some(start), Some(stop)) => + if (isAfter(date, start) && isBefore(date, stop)) { + RangeMiddle; + } else { + NoRange; + } + | _ => NoRange + }; + } + | None => NoRange + } + | _ => NoRange + }; +}; + +let joinClassList = (values: array(string)): string => { + Array.fold_left( + (acc, value) => if (String.length(acc) == 0) { value } else { acc ++ " " ++ value }, + "", + values, + ); +}; + +let ordinalSuffix = (day: int): string => { + let mod100 = day mod 100; + if (mod100 == 11 || mod100 == 12 || mod100 == 13) { + "th"; + } else { + switch (day mod 10) { + | 1 => "st" + | 2 => "nd" + | 3 => "rd" + | _ => "th" + }; + }; +}; + +let formatAriaDayLabel = (date: Js.Date.t, ~isToday: bool): string => { + let prefix = if (isToday) { "Today, " } else { "" }; + prefix ++ getWeekdayName(dayOfWeek(date)) ++ ", " ++ getMonthName(dateMonth(date)) ++ " " ++ string_of_int(dateDay(date)) ++ ordinalSuffix(dateDay(date)) ++ ", " ++ string_of_int(dateYear(date)); +}; + +let getDayClasses = (day: calendarDay, selected: option(selected), today: Js.Date.t, showOutsideDays: bool): string => { + let modifiers = ref([]); + if (isSameDay(day.date, today)) { + modifiers := List.concat([modifiers^, [classNames.dayToday]]); + }; + if (isDateSelected(day.date, selected)) { + modifiers := List.concat([modifiers^, [classNames.daySelected]]); + }; + if (day.isOutside && !showOutsideDays) { + modifiers := List.concat([modifiers^, [classNames.dayHidden]]); + }; + if (day.isOutside) { + modifiers := List.concat([modifiers^, [classNames.dayOutside]]); + }; + switch (getRangePosition(day.date, selected)) { + | RangeStart => modifiers := List.concat([modifiers^, [classNames.dayRangeStart]]) + | RangeMiddle => modifiers := List.concat([modifiers^, [classNames.dayRangeMiddle]]) + | RangeEnd => modifiers := List.concat([modifiers^, [classNames.dayRangeEnd]]) + | NoRange => () + }; + + let classModifiers = Array.of_list(modifiers^); + let joined = joinClassList(classModifiers); + if (String.length(joined) == 0) { + classNames.day; + } else { + classNames.day ++ " " ++ joined; + }; +}; + +let stringProp = (name: string, jsxName: string, value: string): React.JSX.prop => React.JSX.string(name, jsxName, value); +let intProp = (name: string, jsxName: string, value: int): React.JSX.prop => React.JSX.int(name, jsxName, value); +let boolProp = (name: string, jsxName: string, value: bool): React.JSX.prop => React.JSX.bool(name, jsxName, value); +let styleProp = (value: list((string, string, string))): React.JSX.prop => React.JSX.style(value); +let classNameProp = (value: string): React.JSX.prop => React.JSX.string("class", "className", value); +let roleProp = (value: string): React.JSX.prop => React.JSX.string("role", "role", value); +let scopeProp = (value: string): React.JSX.prop => React.JSX.string("scope", "scope", value); +let ariaLabelProp = (value: string): React.JSX.prop => stringProp("aria-label", "ariaLabel", value); +let ariaHiddenProp = (value: string): React.JSX.prop => stringProp("aria-hidden", "ariaHidden", value); + +let element = (~key=?, ~props=[], tag, children) => + React.createElementWithKey(~key?, tag, props, children); + +let renderCaptionChevron = () => + element( + "svg", + ~props=[classNameProp(classNames.chevron), intProp("width", "width", 18), intProp("height", "height", 18), stringProp("viewBox", "viewBox", "0 0 24 24")], + [element("polygon", ~props=[stringProp("points", "points", "6.77 8 12.5 13.57 18.24 8 20 9.72 12.5 17 5 9.72")], [])], + ); + +let renderMonthOption = (monthIndex: int, selectedMonth: int) => + { + let props = + List.concat([ + [stringProp("value", "value", string_of_int(monthIndex))], + monthIndex == selectedMonth ? [stringProp("selected", "selected", "")] : [], + ]); + element( + "option", + ~props, + [React.string(getMonthName(monthIndex))], + ); + }; + +let renderYearOption = (yearValue: int, selectedYear: int) => + { + let props = + List.concat([ + [stringProp("value", "value", string_of_int(yearValue))], + yearValue == selectedYear ? [stringProp("selected", "selected", "")] : [], + ]); + element( + "option", + ~props, + [React.string(string_of_int(yearValue))], + ); + }; + +let renderDropdownRoot = (~disabled: bool, ~selectClassName: string, ~ariaLabel: string, ~selectChildren: array(React.element), ~displayLabel: string) => + element( + "span", + ~props=[stringProp("data-disabled", "dataDisabled", disabled ? "true" : "false"), classNameProp(classNames.dropdownRoot)], + [ + element( + "select", + ~props=List.concat([[classNameProp(selectClassName), ariaLabelProp(ariaLabel)], disabled ? [boolProp("disabled", "disabled", true)] : []]), + [React.array(selectChildren)], + ), + element( + "span", + ~props=[classNameProp(classNames.captionLabel), ariaHiddenProp("true")], + [React.string(displayLabel), renderCaptionChevron()], + ), + ], + ); + +let renderCaptionDropdowns = (~captionLayout: captionLayout, ~monthDate: Js.Date.t, ~navigationDisabled: bool, ~todayYear: int, ~reverseYears: bool) => { + let selectedMonth = dateMonth(monthDate); + let selectedYear = dateYear(monthDate); + let monthOptions = Array.init(12, (monthIndex) => renderMonthOption(monthIndex, selectedMonth)); + let startYear = todayYear - 100; + let yearCount = todayYear - startYear + 1; + let yearOptions = + Array.init(yearCount, (index) => { + let yearValue = reverseYears ? todayYear - index : startYear + index; + renderYearOption(yearValue, selectedYear); + }); + + let liveRegion = + element( + "span", + ~props=[ + roleProp("status"), + stringProp("aria-live", "ariaLive", "polite"), + styleProp([ + ("border", "border", "0"), + ("clip", "clip", "rect(0 0 0 0)"), + ("height", "height", "1px"), + ("margin", "margin", "-1px"), + ("overflow", "overflow", "hidden"), + ("padding", "padding", "0"), + ("position", "position", "absolute"), + ("width", "width", "1px"), + ("white-space", "whiteSpace", "nowrap"), + ("word-wrap", "wordWrap", "normal"), + ]), + ], + [React.string(formatMonthYear(monthDate))], + ); + + switch (captionLayout) { + | `Dropdown => + element( + "div", + ~props=[classNameProp(classNames.dropdowns)], + [ + renderDropdownRoot( + ~disabled=navigationDisabled, + ~selectClassName=classNames.dropdown ++ " " ++ classNames.monthsDropdown, + ~ariaLabel="Choose the Month", + ~selectChildren=monthOptions, + ~displayLabel=getMonthName(selectedMonth), + ), + renderDropdownRoot( + ~disabled=navigationDisabled, + ~selectClassName=classNames.dropdown ++ " " ++ classNames.yearsDropdown, + ~ariaLabel="Choose the Year", + ~selectChildren=yearOptions, + ~displayLabel=string_of_int(selectedYear), + ), + liveRegion, + ], + ) + | `DropdownMonths => + element( + "div", + ~props=[classNameProp(classNames.dropdowns)], + [ + renderDropdownRoot( + ~disabled=navigationDisabled, + ~selectClassName=classNames.dropdown ++ " " ++ classNames.monthsDropdown, + ~ariaLabel="Choose the Month", + ~selectChildren=monthOptions, + ~displayLabel=getMonthName(selectedMonth), + ), + element("span", [React.string(string_of_int(selectedYear))]), + liveRegion, + ], + ) + | `DropdownYears => + element( + "div", + ~props=[classNameProp(classNames.dropdowns)], + [ + element("span", [React.string(getMonthName(selectedMonth))]), + renderDropdownRoot( + ~disabled=navigationDisabled, + ~selectClassName=classNames.dropdown ++ " " ++ classNames.yearsDropdown, + ~ariaLabel="Choose the Year", + ~selectChildren=yearOptions, + ~displayLabel=string_of_int(selectedYear), + ), + liveRegion, + ], + ) + | `Label => liveRegion + }; +}; + +let renderWeekdayHeader = (showWeekNum: bool, ~animate: bool) => { + let weekdayCells = + Array.init(7, (dayIndex) => + element( + "th", + ~props=[ariaLabelProp(getWeekdayName(dayIndex)), classNameProp(classNames.weekday), scopeProp("col")], + [React.string(getWeekdayShort(dayIndex))], + ) + ); + + let weekNumberHeader = + if (showWeekNum) { + element( + "th", + ~props=[ariaLabelProp("Week Number"), classNameProp(classNames.weekNumberHeader), scopeProp("col")], + [React.string("")], + ); + } else { + React.null; + }; + + element( + "thead", + ~props=[stringProp("aria-hidden", "ariaHidden", "true")], + [ + element( + "tr", + ~props= + animate + ? [stringProp("data-animated-weekdays", "dataAnimatedWeekdays", "true"), classNameProp(classNames.weekdays)] + : [classNameProp(classNames.weekdays)], + [weekNumberHeader, React.array(weekdayCells)], + ), + ], + ); +}; + +let renderWeekRow = (week: calendarWeek, ~showWeekNum: bool, selected: option(selected), today: Js.Date.t, showOutsideDays: bool) => { + let weekNumberCell = + if (showWeekNum) { + element( + "th", + ~props=[ariaLabelProp("Week " ++ string_of_int(week.weekNumber)), classNameProp(classNames.weekNumber), scopeProp("row"), roleProp("rowheader")], + [React.string(string_of_int(week.weekNumber))], + ); + } else { + React.null; + }; + + let dayCells = + week.days + |> Array.map((day: calendarDay) => { + let cellClassName = getDayClasses(day, selected, today, showOutsideDays); + let dayIsToday = isSameDay(day.date, today); + let hasContent = showOutsideDays || !day.isOutside; + let tdProps = ref([classNameProp(cellClassName), roleProp("gridcell"), stringProp("data-day", "dataDay", formatISODate(day.date))]); + if (day.isOutside) { + tdProps := + List.concat([ + tdProps^, + showOutsideDays + ? [stringProp("data-month", "dataMonth", formatYearMonth(day.date)), stringProp("data-outside", "dataOutside", "true")] + : [ + stringProp("data-month", "dataMonth", formatYearMonth(day.date)), + stringProp("data-hidden", "dataHidden", "true"), + stringProp("data-outside", "dataOutside", "true"), + ], + ]); + }; + if (dayIsToday) { + tdProps := List.concat([tdProps^, [stringProp("data-today", "dataToday", "true")]]); + }; + + let content = + if (hasContent) { + element( + "button", + ~props=[ + classNameProp(classNames.dayButton), + stringProp("type", "type", "button"), + intProp("tabindex", "tabIndex", dayIsToday ? 0 : -1), + ariaLabelProp(formatAriaDayLabel(day.date, ~isToday=dayIsToday)), + ], + [React.string(formatDayNumber(day.date))], + ); + } else { + React.string(""); + }; + + element("td", ~props=tdProps^, [content]); + }); + + element("tr", ~props=[classNameProp(classNames.week)], [weekNumberCell, React.array(dayCells)]); +}; + +let renderMonth = ( + monthIndex: int, + ~showOutsideDays: bool, + ~showWeekNumber: bool, + ~hideWeekdays: bool, + ~fixedWeeks: bool, + ~captionLayout: option(captionLayout), + ~reverseYears: bool, + ~animate: bool, + ~navigationDisabled: bool, + ~embeddedNavigation: option(React.element), + ~todayYear: int, + today: Js.Date.t, + selected: option(selected), +) => { + let monthStart = startOfMonth(today) |> setMonth(_, dateMonth(today) + monthIndex); + let weeks = buildMonthWeeks(monthStart, ~weekStartsOn=0, ~fixedWeeks, ()); + let weekdayHeader = hideWeekdays ? React.null : renderWeekdayHeader(showWeekNumber, ~animate); + let weekRows = + weeks + |> Array.map((week: calendarWeek) => renderWeekRow(week, ~showWeekNum=showWeekNumber, selected, today, showOutsideDays)); + + let captionContent = + switch (captionLayout) { + | Some(`Dropdown as layout) + | Some(`DropdownMonths as layout) + | Some(`DropdownYears as layout) => + renderCaptionDropdowns(~captionLayout=layout, ~monthDate=monthStart, ~navigationDisabled, ~todayYear, ~reverseYears) + | _ => + element( + "span", + ~props=[classNameProp(classNames.captionLabel), roleProp("status"), stringProp("aria-live", "ariaLive", "polite")], + [React.string(formatMonthYear(monthStart))], + ) + }; + + let captionProps = + animate + ? [stringProp("data-animated-caption", "dataAnimatedCaption", "true"), classNameProp(classNames.monthCaption)] + : [classNameProp(classNames.monthCaption)]; + let monthProps = + animate + ? [stringProp("data-animated-month", "dataAnimatedMonth", "true"), classNameProp(classNames.month)] + : [classNameProp(classNames.month)]; + let weeksProps = + animate + ? [stringProp("data-animated-weeks", "dataAnimatedWeeks", "true"), classNameProp(classNames.weeks)] + : [classNameProp(classNames.weeks)]; + let monthChildren = + switch (embeddedNavigation) { + | Some(navigation) => + [ + element("div", ~props=captionProps, [captionContent]), + navigation, + element( + "table", + ~props=[roleProp("grid"), stringProp("aria-multiselectable", "ariaMultiselectable", "false"), ariaLabelProp(formatMonthYear(monthStart)), classNameProp(classNames.monthGrid)], + [weekdayHeader, element("tbody", ~props=weeksProps, [React.array(weekRows)])], + ), + ] + | None => + [ + element("div", ~props=captionProps, [captionContent]), + element( + "table", + ~props=[roleProp("grid"), stringProp("aria-multiselectable", "ariaMultiselectable", "false"), ariaLabelProp(formatMonthYear(monthStart)), classNameProp(classNames.monthGrid)], + [weekdayHeader, element("tbody", ~props=weeksProps, [React.array(weekRows)])], + ), + ] + }; + + element("div", ~key=string_of_int(monthIndex), ~props=monthProps, monthChildren); +}; + +let renderNavigation = (~animate: bool, ~navigationDisabled: bool) => { + let chevron = (points: string) => + element( + "svg", + ~props=[classNameProp(classNames.chevron), intProp("width", "width", 24), intProp("height", "height", 24), stringProp("viewBox", "viewBox", "0 0 24 24")], + [element("polygon", ~props=[stringProp("points", "points", points)], [])], + ); + + element( + "nav", + ~props= + animate + ? [stringProp("data-animated-nav", "dataAnimatedNav", "true"), classNameProp(classNames.nav), ariaLabelProp("Navigation bar")] + : [classNameProp(classNames.nav), ariaLabelProp("Navigation bar")], + [ + element( + "button", + ~props= + navigationDisabled + ? [stringProp("type", "type", "button"), classNameProp(classNames.buttonPrevious), intProp("tabindex", "tabIndex", -1), stringProp("aria-disabled", "ariaDisabled", "true"), ariaLabelProp("Go to the Previous Month")] + : [stringProp("type", "type", "button"), classNameProp(classNames.buttonPrevious), ariaLabelProp("Go to the Previous Month")], + [chevron("16 18.112 9.81111111 12 16 5.87733333 14.0888889 4 6 12 14.0888889 20")], + ), + element( + "button", + ~props= + navigationDisabled + ? [stringProp("type", "type", "button"), classNameProp(classNames.buttonNext), intProp("tabindex", "tabIndex", -1), stringProp("aria-disabled", "ariaDisabled", "true"), ariaLabelProp("Go to the Next Month")] + : [stringProp("type", "type", "button"), classNameProp(classNames.buttonNext), ariaLabelProp("Go to the Next Month")], + [chevron("8 18.112 14.18888889 12 8 5.87733333 9.91111111 4 18 12 9.91111111 20")], + ), + ], + ); +}; + +let make = ( + ~mode: option(mode)=?, + ~onSelect: option(onSelect)=?, + ~selected: option(selected)=?, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + ~key: option(string)=?, + (), +) => { + ignore(onSelect); + ignore(key); + let currentMode = + switch (mode) { + | Some(value) => modeToString(value) + | None => modeToString(`Single) + }; + let reverseYearsValue = + switch (reverseYears) { + | Some(value) => value + | None => false + }; + let navLayoutAfter = + switch (navLayout) { + | Some(`After) => true + | _ => false + }; + let navigationDisabled = + switch (disableNavigation) { + | Some(value) => value + | None => false + }; + let navigationHidden = + switch (hideNavigation) { + | Some(value) => value + | None => false + }; + let animateValue = + switch (animate) { + | Some(value) => value + | None => false + }; + ignore(pagedNavigation); + let showOutside = switch (showOutsideDays) { + | Some(value) => value + | None => false + }; + let showWeekNum = switch (showWeekNumber) { + | Some(value) => value + | None => false + }; + let hideWeekdaysValue = switch (hideWeekdays) { + | Some(value) => value + | None => false + }; + let numberOfMonthsValue = switch (numberOfMonths) { + | Some(value) => value + | None => 1 + }; + let reverseMonthsValue = switch (reverseMonths) { + | Some(value) => value + | None => false + }; + let fixedWeeksValue = + switch (fixedWeeks) { + | Some(value) => value + | None => false + }; + let today = Js.Date.make(); + let todayYear = dateYear(today); + let navigationElement = + if (navigationHidden) { + None; + } else { + Some(renderNavigation(~animate=animateValue, ~navigationDisabled)); + }; + let monthIndices = Array.init(numberOfMonthsValue, (idx) => if (reverseMonthsValue) { numberOfMonthsValue - idx - 1 } else { idx }); + let monthElements = + monthIndices + |> Array.mapi((renderIndex, monthIndex) => { + let embeddedNavigation = + if (navLayoutAfter && renderIndex == numberOfMonthsValue - 1) { + navigationElement; + } else { + None; + }; + renderMonth( + monthIndex, + ~showOutsideDays=showOutside, + ~showWeekNumber=showWeekNum, + ~hideWeekdays=hideWeekdaysValue, + ~fixedWeeks=fixedWeeksValue, + ~captionLayout, + ~reverseYears=reverseYearsValue, + ~animate=animateValue, + ~navigationDisabled, + ~embeddedNavigation, + ~todayYear, + today, + selected, + ); + }); + + let monthsChildren = + switch (navigationElement) { + | Some(value) when !navLayoutAfter => [value, React.array(monthElements)] + | _ => [React.array(monthElements)] + }; + + let footerElement = + switch (footer) { + | Some(value) => + element("div", ~props=[classNameProp(classNames.footer), roleProp("status"), stringProp("aria-live", "ariaLive", "polite")], [value]); + | None => React.null + }; + + let rootProps = + List.concat([ + [classNameProp(classNames.root), stringProp("lang", "lang", "en-US"), stringProp("data-mode", "dataMode", currentMode)], + numberOfMonthsValue > 1 ? [stringProp("data-multiple-months", "dataMultipleMonths", "true")] : [], + showWeekNum ? [stringProp("data-week-numbers", "dataWeekNumbers", "true")] : [], + navLayoutAfter ? [stringProp("data-nav-layout", "dataNavLayout", navLayoutToString(`After))] : [], + ]); + + element( + "div", + ~props=rootProps, + [element("div", ~props=[classNameProp(classNames.months)], monthsChildren), footerElement], + ); +}; diff --git a/dune b/dune index d7e5a60..7666b24 100644 --- a/dune +++ b/dune @@ -1,9 +1,18 @@ (library - (wrapped false) - (modules ReactDayPicker) (name react_day_picker) (public_name reason-react-day-picker) + (wrapped false) + (modules ReactDayPicker) (modes melange) (libraries reason-react melange.js) (preprocess - (pps melange.ppx reason-react-ppx))) + (pps melange.ppx reason-react-ppx))) + +(library + (name react_day_picker_native) + (public_name reason-react-day-picker.native) + (wrapped false) + (modes native) + (modules ReactDayPickerNative) + (optional) + (libraries server-reason-react.react server-reason-react.js)) diff --git a/dune-project b/dune-project index fd1c9e3..09950eb 100644 --- a/dune-project +++ b/dune-project @@ -16,19 +16,20 @@ (using melange 0.1) (package - (name reason-react-day-picker) - (synopsis "Melange bindings for react-day-picker") - (description "Melange bindings for react-day-picker written in ReasonML") - (depends - ocaml - melange - reason - melange-webapi - ppxlib - reason-react-ppx - reason-react) - (allow_empty) - (tags - ("melange" "reason" "bindings" "react-day-picker" "react"))) + (name reason-react-day-picker) + (synopsis "Melange bindings for react-day-picker") + (description "Melange bindings for react-day-picker written in ReasonML") + (depends + ocaml + melange + reason + melange-webapi + ppxlib + reason-react-ppx + reason-react + server-reason-react) + (allow_empty) + (tags + ("melange" "reason" "bindings" "react-day-picker" "react"))) ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html diff --git a/example/README.md b/example/README.md new file mode 100644 index 0000000..4b8bee2 --- /dev/null +++ b/example/README.md @@ -0,0 +1,53 @@ +# Example App + +This folder follows the same split used by `server-reason-react` demos: + +- `example/shared/`: fixture values shared by both targets +- `example/js/`: Melange renderer using `ReactDOMServer.renderToString` +- `example/native/`: native renderer using `ReactDOM.renderToString` + +Both renderers use the same props and should produce matching HTML for the same +fixture values in `example/shared/SharedFixture.re`, including `footer` via +`React.string(...)`. + +### Run the native renderer + +```bash +dune exec ./example/native/NativeRenderer.exe +``` + +### Run the Melange renderer + +The JS renderer is emitted by Melange and executed with Node. Runtime execution +needs the npm packages used by the binding: + +```bash +npm install react react-dom react-day-picker +dune build example/js/render/example/js/JsRenderer.re.js +node _build/default/example/js/render/example/js/JsRenderer.re.js +``` + +### Compare outputs + +```bash +dune exec ./example/native/NativeRenderer.exe > /tmp/native.txt +dune build example/js/render/example/js/JsRenderer.re.js +node _build/default/example/js/render/example/js/JsRenderer.re.js > /tmp/js.txt +diff -u /tmp/native.txt /tmp/js.txt +``` + +Both files include `RENDER_START` / `RENDER_END` markers so you can diff the +HTML section directly. + +### Run all parity scenarios + +```bash +npm install react react-dom react-day-picker +./example/check-parity.sh +``` + +To run a subset: + +```bash +./example/check-parity.sh default nav-after animate +``` diff --git a/example/check-parity.sh b/example/check-parity.sh new file mode 100755 index 0000000..1541a24 --- /dev/null +++ b/example/check-parity.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +if [[ ! -d node_modules/react || ! -d node_modules/react-dom || ! -d node_modules/react-day-picker ]]; then + printf 'Install npm runtime deps first: npm install react react-dom react-day-picker\n' >&2 + exit 1 +fi + +scenarios=( + default + outside-hidden + multi-months + reverse-months + nav-after + disable-navigation + hide-navigation + fixed-weeks + animate + caption-dropdown + caption-dropdown-months + caption-dropdown-years + reverse-years + paged-navigation +) + +if [[ $# -gt 0 ]]; then + scenarios=("$@") +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +dune build -j1 @example/js/melange example/native/NativeRenderer.exe + +for scenario in "${scenarios[@]}"; do + node "_build/default/example/js/render/example/js/JsRenderer.re.js" "$scenario" > "$tmpdir/js.txt" + dune exec ./example/native/NativeRenderer.exe -- "$scenario" > "$tmpdir/native.txt" + if ! diff -u "$tmpdir/native.txt" "$tmpdir/js.txt"; then + printf 'Parity failed for scenario: %s\n' "$scenario" >&2 + exit 1 + fi + printf 'ok %s\n' "$scenario" +done + +printf 'All parity scenarios matched.\n' diff --git a/example/js/JsRenderer.re b/example/js/JsRenderer.re new file mode 100644 index 0000000..800519f --- /dev/null +++ b/example/js/JsRenderer.re @@ -0,0 +1,56 @@ +open SharedFixture; + +let config = Scenario.current(); + +let demoDate = + Js.Date.make( + ~year=float_of_int(demoYear), + ~month=float_of_int(demoMonth), + ~date=float_of_int(demoDay), + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), + ); + +let footer = + switch (config.footerText) { + | Some(value) => Some(React.string(value)) + | None => None + }; + +let captionLayout = config.captionLayout; +let navLayout = config.navLayout; +let onSelect = `Single((_date: ReactDayPicker.singleDate) => ()); +let selected = `Single(Js.Undefined.return(demoDate)); + +let dayPicker = { + let props = + ReactDayPicker.makeProps( + ~mode=`Single, + ~onSelect, + ~selected, + ~captionLayout?, + ~reverseYears=config.reverseYears, + ~navLayout?, + ~disableNavigation=config.disableNavigation, + ~hideNavigation=config.hideNavigation, + ~animate=config.animate, + ~fixedWeeks=config.fixedWeeks, + ~footer?, + ~hideWeekdays=config.hideWeekdays, + ~numberOfMonths=config.numberOfMonths, + ~reverseMonths=config.reverseMonths, + ~pagedNavigation=config.pagedNavigation, + ~showOutsideDays=config.showOutsideDays, + ~showWeekNumber=config.showWeekNumber, + (), + ); + React.createElement(ReactDayPicker.make, props); +}; + +let rendered = ReactDOMServer.renderToString(dayPicker); + +let () = print_endline("RENDER_START"); +let () = print_endline(rendered); +let () = print_endline("RENDER_END"); diff --git a/example/js/dune b/example/js/dune new file mode 100644 index 0000000..4bf3608 --- /dev/null +++ b/example/js/dune @@ -0,0 +1,9 @@ +(copy_files# "../shared/*.re") + +(melange.emit + (target render) + (module_systems + (commonjs re.js)) + (libraries reason-react-day-picker reason-react melange.js) + (preprocess + (pps melange.ppx reason-react-ppx))) diff --git a/example/native/NativeRenderer.re b/example/native/NativeRenderer.re new file mode 100644 index 0000000..2d613d7 --- /dev/null +++ b/example/native/NativeRenderer.re @@ -0,0 +1,49 @@ +open SharedFixture; + +let config = Scenario.current(); + +let demoDate = + Js.Date.makeWithYMDHMS( + ~year=float_of_int(demoYear), + ~month=float_of_int(demoMonth), + ~date=float_of_int(demoDay), + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + ); + +let footer = + switch (config.footerText) { + | Some(value) => Some(React.string(value)) + | None => None + }; + +let captionLayout = config.captionLayout; +let navLayout = config.navLayout; + +let dayPicker = + ReactDayPickerNative.make( + ~mode=`Single, + ~selected=`Single(Js.Undefined.fromOption(Some(demoDate))), + ~captionLayout?, + ~reverseYears=config.reverseYears, + ~navLayout?, + ~disableNavigation=config.disableNavigation, + ~hideNavigation=config.hideNavigation, + ~animate=config.animate, + ~fixedWeeks=config.fixedWeeks, + ~footer?, + ~hideWeekdays=config.hideWeekdays, + ~numberOfMonths=config.numberOfMonths, + ~reverseMonths=config.reverseMonths, + ~pagedNavigation=config.pagedNavigation, + ~showOutsideDays=config.showOutsideDays, + ~showWeekNumber=config.showWeekNumber, + (), + ); + +let rendered = ReactDOM.renderToString(dayPicker); + +let () = print_endline("RENDER_START"); +let () = print_endline(rendered); +let () = print_endline("RENDER_END"); diff --git a/example/native/dune b/example/native/dune new file mode 100644 index 0000000..62d776a --- /dev/null +++ b/example/native/dune @@ -0,0 +1,9 @@ +(copy_files# "../shared/*.re") + +(executable + (name NativeRenderer) + (libraries + reason-react-day-picker.native + server-reason-react.react + server-reason-react.reactDom + server-reason-react.js)) diff --git a/example/shared/Scenario.re b/example/shared/Scenario.re new file mode 100644 index 0000000..4bc94c5 --- /dev/null +++ b/example/shared/Scenario.re @@ -0,0 +1,97 @@ +type captionLayout = [ + | `Label + | `Dropdown + | `DropdownMonths + | `DropdownYears +]; + +type navLayout = [ + | `Around + | `After +]; + +type config = { + name: string, + captionLayout: option(captionLayout), + reverseYears: bool, + navLayout: option(navLayout), + disableNavigation: bool, + hideNavigation: bool, + animate: bool, + fixedWeeks: bool, + footerText: option(string), + hideWeekdays: bool, + numberOfMonths: int, + reverseMonths: bool, + pagedNavigation: bool, + showOutsideDays: bool, + showWeekNumber: bool, +}; + +let all = [| + "default", + "outside-hidden", + "multi-months", + "reverse-months", + "nav-after", + "disable-navigation", + "hide-navigation", + "fixed-weeks", + "animate", + "caption-dropdown", + "caption-dropdown-months", + "caption-dropdown-years", + "reverse-years", + "paged-navigation", +|]; + +let base = { + name: "default", + captionLayout: None, + reverseYears: false, + navLayout: None, + disableNavigation: false, + hideNavigation: false, + animate: false, + fixedWeeks: false, + footerText: Some("Shared native/js sample"), + hideWeekdays: false, + numberOfMonths: 1, + reverseMonths: false, + pagedNavigation: false, + showOutsideDays: true, + showWeekNumber: true, +}; + +let byName = (name: string): config => + switch (name) { + | "outside-hidden" => {...base, name, showOutsideDays: false} + | "multi-months" => {...base, name, numberOfMonths: 2} + | "reverse-months" => {...base, name, numberOfMonths: 2, reverseMonths: true} + | "nav-after" => {...base, name, navLayout: Some(`After)} + | "disable-navigation" => {...base, name, disableNavigation: true} + | "hide-navigation" => {...base, name, hideNavigation: true} + | "fixed-weeks" => {...base, name, fixedWeeks: true, showOutsideDays: false} + | "animate" => {...base, name, animate: true} + | "caption-dropdown" => {...base, name, captionLayout: Some(`Dropdown), showOutsideDays: false, showWeekNumber: false} + | "caption-dropdown-months" => {...base, name, captionLayout: Some(`DropdownMonths), showOutsideDays: false, showWeekNumber: false} + | "caption-dropdown-years" => {...base, name, captionLayout: Some(`DropdownYears), showOutsideDays: false, showWeekNumber: false} + | "reverse-years" => {...base, name, captionLayout: Some(`DropdownYears), reverseYears: true, showOutsideDays: false, showWeekNumber: false} + | "paged-navigation" => {...base, name, numberOfMonths: 2, pagedNavigation: true} + | _ => base + }; + +let current = (): config => { + let name = + if (Array.length(Sys.argv) > 1) { + let candidate = Sys.argv[Array.length(Sys.argv) - 1]; + if (Array.exists((value) => value == candidate, all)) { + candidate; + } else { + "default"; + }; + } else { + "default"; + }; + byName(name); +}; diff --git a/example/shared/SharedFixture.re b/example/shared/SharedFixture.re new file mode 100644 index 0000000..b276b8e --- /dev/null +++ b/example/shared/SharedFixture.re @@ -0,0 +1,9 @@ +let demoYear = 2026; +let demoMonth = 0; +let demoDay = 15; + +let numberOfMonths = 1; +let showOutsideDays = true; +let showWeekNumber = true; +let hideWeekdays = false; +let footerText = "Shared native/js sample"; From 7f6f1510e92d481ca17cbb643cd4fbd11f6acea2 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Sat, 21 Mar 2026 14:46:01 -0400 Subject: [PATCH 02/12] add universal ReactDayPicker component Expose the same JSX-friendly ReactDayPicker API on Melange and native so shared code can render on both targets without separate module names or prop shapes. --- README.md | 116 ++++---- ReactDayPicker.re | 479 ++++++++++++++++++++++++------- dune | 11 +- dune-project | 29 +- example/README.md | 6 +- example/js/JsRenderer.re | 44 ++- example/native/NativeRenderer.re | 39 +-- example/native/dune | 2 + native/dune | 13 + reason-react-day-picker.opam | 2 +- 10 files changed, 508 insertions(+), 233 deletions(-) create mode 100644 native/dune diff --git a/README.md b/README.md index 4cd2160..63bfcfc 100644 --- a/README.md +++ b/README.md @@ -36,52 +36,58 @@ opam install server-reason-react ### Melange (Browser) ```ocaml -let props = - ReactDayPicker.makeProps( - ~mode=`Range, - ~selected=`Range(Js.Undefined.return({ - ReactDayPicker.from: Js.Undefined.return(openDate), - ReactDayPicker.to_: Js.Undefined.return(closeDate), - })), - ~onSelect=`Range((dates: ReactDayPicker.rangeDate) => { - switch (dates->Js.Undefined.toOption) { - | Some(dates) => - let openDate = - switch (dates.from->Js.Undefined.toOption) { - | Some(date) => date - | None => today - }; - let closeDate = - switch (dates.to_->Js.Undefined.toOption) { - | Some(date) => date - | None => openDate - }; - updateOpenDate(openDate); - updateCloseDate(closeDate); - | None => - updateOpenDate(today); - updateCloseDate(today); - }; - }), - (), - ); - -let calendar = React.createElement(ReactDayPicker.make, props); +let calendar = + { + switch (dates->Js.Undefined.toOption) { + | Some(dates) => + let openDate = + switch (dates.from->Js.Undefined.toOption) { + | Some(date) => date + | None => today + }; + let closeDate = + switch (dates.to_->Js.Undefined.toOption) { + | Some(date) => date + | None => openDate + }; + setOpenDate(_prev => openDate); + setCloseDate(_prev => closeDate); + | None => + setOpenDate(_prev => today); + setCloseDate(_prev => today); + } + }) + } + />; ``` ### Native (Server-Side Rendering) +The native package now exposes the same `ReactDayPicker` component name as the +Melange package, so the same JSX can render on both targets. + ```reason let today = Js.Date.make(); let calendar = - ReactDayPickerNative.make( - ~mode=`Single, - ~selected=`Single(Some(today)), - ~numberOfMonths=1, - ~showOutsideDays=true, - (), - ); + ())} + selected={`Single(Js.Undefined.return(today))} + numberOfMonths=1 + showOutsideDays=true + />; /* Render to HTML string */ let html = ReactDOM.renderToString(calendar); @@ -101,14 +107,15 @@ node _build/default/example/js/render/example/js/JsRenderer.re.js The Melange renderer needs `react`, `react-dom`, and `react-day-picker` installed in `node_modules` to run under Node. -#### Native Props +#### Props -The native implementation supports the following props: +The shared `ReactDayPicker` component supports the following props: | Prop | Type | Description | |------|------|-------------| -| `mode` | `option(mode)` | Selection mode using `` `Single ``, `` `Multiple ``, or `` `Range `` | -| `selected` | `option(selected)` | Selected date(s) | +| `mode` | `string` | One of `"single"`, `"multiple"`, or `"range"` | +| `selected` | `selected` | Selected date(s) | +| `onSelect` | `onSelect` | Selection callback | | `captionLayout` | `option(captionLayout)` | Caption layout using typed constructors | | `navLayout` | `option(navLayout)` | Navigation layout using typed constructors | | `numberOfMonths` | `option(int)` | Number of months to display (default: 1) | @@ -120,11 +127,7 @@ The native implementation supports the following props: #### Selection Types ```reason -type mode = [ - | `Single - | `Multiple - | `Range -]; +type mode = string; type captionLayout = [ | `Label @@ -138,15 +141,24 @@ type navLayout = [ | `After ]; +type singleDate = Js.Undefined.t(Js.Date.t); +type multipleDate = Js.Undefined.t(array(Js.Date.t)); + type selected = [ - | `Single(Js.Date.t option) - | `Multiple(array(Js.Date.t) option) - | `Range(dateRange option) + | `Single(singleDate) + | `Multiple(multipleDate) + | `Range(rangeDate) +]; + +type onSelect = [ + | `Single(singleDate => unit) + | `Multiple(multipleDate => unit) + | `Range(rangeDate => unit) ]; type dateRange = { - from: Js.Date.t option, - to_: Js.Date.t option, + from: singleDate, + to_: singleDate, }; type reactNode = React.element; diff --git a/ReactDayPicker.re b/ReactDayPicker.re index 6f8bf7e..d4757ee 100644 --- a/ReactDayPicker.re +++ b/ReactDayPicker.re @@ -1,15 +1,4 @@ -type mode = [ - | `Single - | `Multiple - | `Range -]; - -let modeToString = (v: mode): string => - switch (v) { - | `Single => "single" - | `Multiple => "multiple" - | `Range => "range" - }; +type mode = string; type captionLayout = [ | `Label @@ -18,8 +7,8 @@ type captionLayout = [ | `DropdownYears ]; -let captionLayoutToString = (v: captionLayout): string => - switch (v) { +let captionLayoutToString = (value: captionLayout): string => + switch (value) { | `Label => "label" | `Dropdown => "dropdown" | `DropdownMonths => "dropdown-months" @@ -31,22 +20,22 @@ type navLayout = [ | `After ]; -let navLayoutToString = (v: navLayout): string => - switch (v) { +let navLayoutToString = (value: navLayout): string => + switch (value) { | `Around => "around" | `After => "after" }; type reactNode = React.element; -/* `reactNode` can be created with `React.string`, `React.int`, JSX, etc. */ - type singleDate = Js.Undefined.t(Js.Date.t); type multipleDate = Js.Undefined.t(array(Js.Date.t)); + type dateRange = { from: singleDate, - [@mel.as "to"] to_: singleDate, + to_: singleDate, }; + type rangeDate = Js.Undefined.t(dateRange); type selected = [ @@ -61,95 +50,367 @@ type onSelect = [ | `Range(rangeDate => unit) ]; -[@mel.obj] -external makeProps: - ( - ~mode: - [@mel.string] [ - | [@mel.as "single"] `Single - | [@mel.as "multiple"] `Multiple - | [@mel.as "range"] `Range - ], - ~onSelect: - [@mel.unwrap] [ - | `Single(singleDate => unit) - | `Multiple(multipleDate => unit) - | `Range(rangeDate => unit) - ], - ~selected: - [@mel.unwrap] [ - | `Single(singleDate) - | `Multiple(multipleDate) - | `Range(rangeDate) - ], - ~captionLayout: - [@mel.string] [ - | [@mel.as "label"] `Label - | [@mel.as "dropdown"] `Dropdown - | [@mel.as "dropdown-months"] `DropdownMonths - | [@mel.as "dropdown-years"] `DropdownYears - ]=?, - ~reverseYears: bool=?, - ~navLayout: - [@mel.string] [ - | [@mel.as "around"] `Around - | [@mel.as "after"] `After - ]=?, - ~disableNavigation: bool=?, - ~hideNavigation: bool=?, - ~animate: bool=?, - ~fixedWeeks: bool=?, - ~footer: reactNode=?, - ~hideWeekdays: bool=?, - ~numberOfMonths: int=?, - ~reverseMonths: bool=?, - ~pagedNavigation: bool=?, - ~showOutsideDays: bool=?, - ~showWeekNumber: bool=?, - ~key: string=?, - unit - ) => - { - . - "mode": string, - "selected": 'selected, - "onSelect": 'onSelect, - "captionLayout": string, - "reverseYears": bool, - "navLayout": string, - "disableNavigation": bool, - "hideNavigation": bool, - "animate": bool, - "fixedWeeks": bool, - "footer": reactNode, - "hideWeekdays": bool, - "numberOfMonths": int, - "reverseMonths": bool, - "pagedNavigation": bool, - "showOutsideDays": bool, - "showWeekNumber": bool, +[@platform js] +module ClientImpl = { + type jsDateRange = { + from: singleDate, + [@mel.as "to"] to_: singleDate, }; -[@mel.module "react-day-picker"] -external make: - { - .. - "mode": string, - "onSelect": 'onSelect, - "selected": 'selected, - "captionLayout": string, - "navLayout": string, - "disableNavigation": bool, - "hideNavigation": bool, - "animate": bool, - "fixedWeeks": bool, - "footer": reactNode, - "hideWeekdays": bool, - "numberOfMonths": int, - "reverseMonths": bool, - "pagedNavigation": bool, - "showOutsideDays": bool, - "showWeekNumber": bool, - } => - React.element = - "DayPicker"; + type jsRangeDate = Js.Undefined.t(jsDateRange); + + type jsSelected = [ + | `Single(singleDate) + | `Multiple(multipleDate) + | `Range(jsRangeDate) + ]; + + type jsOnSelect = [ + | `Single(singleDate => unit) + | `Multiple(multipleDate => unit) + | `Range(jsRangeDate => unit) + ]; + + let emptyRangeDate: jsRangeDate = Js.Undefined.fromOption(None); + + let toJsRangeDate = (value: rangeDate): jsRangeDate => + switch (Js.Undefined.toOption(value)) { + | Some(rangeValue) => + let nextValue: jsDateRange = { + from: rangeValue.from, + to_: rangeValue.to_, + }; + Js.Undefined.return(nextValue) + | None => emptyRangeDate + }; + + let fromJsRangeDate = (value: jsRangeDate): rangeDate => + switch (Js.Undefined.toOption(value)) { + | Some(rangeValue) => + let nextValue: dateRange = { + from: rangeValue.from, + to_: rangeValue.to_, + }; + Js.Undefined.return(nextValue) + | None => Js.Undefined.fromOption(None) + }; + + let toJsSelected = (value: selected): jsSelected => + switch (value) { + | `Single(date) => `Single(date) + | `Multiple(dates) => `Multiple(dates) + | `Range(rangeValue) => `Range(toJsRangeDate(rangeValue)) + }; + + let toJsOnSelect = (value: onSelect): jsOnSelect => + switch (value) { + | `Single(callback) => `Single(callback) + | `Multiple(callback) => `Multiple(callback) + | `Range(callback) => + `Range((dates: jsRangeDate) => callback(fromJsRangeDate(dates))) + }; + + [@mel.obj] + external makeProps: + ( + ~mode: string, + ~onSelect: jsOnSelect, + ~selected: jsSelected, + ~captionLayout: string=?, + ~reverseYears: bool=?, + ~navLayout: string=?, + ~disableNavigation: bool=?, + ~hideNavigation: bool=?, + ~animate: bool=?, + ~fixedWeeks: bool=?, + ~footer: reactNode=?, + ~hideWeekdays: bool=?, + ~numberOfMonths: int=?, + ~reverseMonths: bool=?, + ~pagedNavigation: bool=?, + ~showOutsideDays: bool=?, + ~showWeekNumber: bool=?, + unit, + ) => + { + . + "mode": string, + "selected": jsSelected, + "onSelect": jsOnSelect, + "captionLayout": string, + "reverseYears": bool, + "navLayout": string, + "disableNavigation": bool, + "hideNavigation": bool, + "animate": bool, + "fixedWeeks": bool, + "footer": reactNode, + "hideWeekdays": bool, + "numberOfMonths": int, + "reverseMonths": bool, + "pagedNavigation": bool, + "showOutsideDays": bool, + "showWeekNumber": bool, + }; + + [@mel.module "react-day-picker"] + external make: + { + .. + "mode": string, + "onSelect": jsOnSelect, + "selected": jsSelected, + "captionLayout": string, + "navLayout": string, + "disableNavigation": bool, + "hideNavigation": bool, + "animate": bool, + "fixedWeeks": bool, + "footer": reactNode, + "hideWeekdays": bool, + "numberOfMonths": int, + "reverseMonths": bool, + "pagedNavigation": bool, + "showOutsideDays": bool, + "showWeekNumber": bool, + } => + React.element = + "DayPicker"; + + let render = ( + ~mode, + ~selected, + ~onSelect, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + (), + ) => { + let captionLayout = + switch (captionLayout) { + | Some(value) => Some(captionLayoutToString(value)) + | None => None + }; + let navLayout = + switch (navLayout) { + | Some(value) => Some(navLayoutToString(value)) + | None => None + }; + let props = + makeProps( + ~mode, + ~onSelect=toJsOnSelect(onSelect), + ~selected=toJsSelected(selected), + ~captionLayout?, + ~reverseYears?, + ~navLayout?, + ~disableNavigation?, + ~hideNavigation?, + ~animate?, + ~fixedWeeks?, + ~footer?, + ~hideWeekdays?, + ~numberOfMonths?, + ~reverseMonths?, + ~pagedNavigation?, + ~showOutsideDays?, + ~showWeekNumber?, + (), + ); + React.createElement(make, props); + }; +}; + +[@platform native] +module ServerImpl = { + let modeFromString = (value: mode): ReactDayPickerNative.mode => + switch (value) { + | "multiple" => `Multiple + | "range" => `Range + | _ => `Single + }; + + let captionLayoutFromShared = (value: captionLayout): ReactDayPickerNative.captionLayout => + switch (value) { + | `Label => `Label + | `Dropdown => `Dropdown + | `DropdownMonths => `DropdownMonths + | `DropdownYears => `DropdownYears + }; + + let navLayoutFromShared = (value: navLayout): ReactDayPickerNative.navLayout => + switch (value) { + | `Around => `Around + | `After => `After + }; + + let emptyRangeDate: ReactDayPickerNative.rangeDate = Js.Undefined.fromOption(None); + + let toNativeRangeDate = (value: rangeDate): ReactDayPickerNative.rangeDate => + switch (Js.Undefined.toOption(value)) { + | Some(rangeValue) => + let nextValue: ReactDayPickerNative.dateRange = { + from: rangeValue.from, + to_: rangeValue.to_, + }; + Js.Undefined.return(nextValue) + | None => emptyRangeDate + }; + + let fromNativeRangeDate = (value: ReactDayPickerNative.rangeDate): rangeDate => + switch (Js.Undefined.toOption(value)) { + | Some(rangeValue) => + let nextValue: dateRange = { + from: rangeValue.from, + to_: rangeValue.to_, + }; + Js.Undefined.return(nextValue) + | None => Js.Undefined.fromOption(None) + }; + + let toNativeSelected = (value: selected): ReactDayPickerNative.selected => + switch (value) { + | `Single(date) => `Single(date) + | `Multiple(dates) => `Multiple(dates) + | `Range(rangeValue) => `Range(toNativeRangeDate(rangeValue)) + }; + + let toNativeOnSelect = (value: onSelect): ReactDayPickerNative.onSelect => + switch (value) { + | `Single(callback) => `Single(callback) + | `Multiple(callback) => `Multiple(callback) + | `Range(callback) => + `Range((dates: ReactDayPickerNative.rangeDate) => + callback(fromNativeRangeDate(dates)) + ) + }; + + let render = ( + ~mode, + ~selected, + ~onSelect, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + (), + ) => { + let captionLayout = + switch (captionLayout) { + | Some(value) => Some(captionLayoutFromShared(value)) + | None => None + }; + let navLayout = + switch (navLayout) { + | Some(value) => Some(navLayoutFromShared(value)) + | None => None + }; + ReactDayPickerNative.make( + ~mode=modeFromString(mode), + ~onSelect=toNativeOnSelect(onSelect), + ~selected=toNativeSelected(selected), + ~captionLayout?, + ~reverseYears?, + ~navLayout?, + ~disableNavigation?, + ~hideNavigation?, + ~animate?, + ~fixedWeeks?, + ~footer?, + ~hideWeekdays?, + ~numberOfMonths?, + ~reverseMonths?, + ~pagedNavigation?, + ~showOutsideDays?, + ~showWeekNumber?, + (), + ); + }; +}; + +[@react.component] +let make = ( + ~mode: mode, + ~selected: selected, + ~onSelect: onSelect, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + (), +) => + switch%platform (Runtime.platform) { + | Server => + ServerImpl.render( + ~mode, + ~selected, + ~onSelect, + ~captionLayout?, + ~reverseYears?, + ~navLayout?, + ~disableNavigation?, + ~hideNavigation?, + ~animate?, + ~fixedWeeks?, + ~footer?, + ~hideWeekdays?, + ~numberOfMonths?, + ~reverseMonths?, + ~pagedNavigation?, + ~showOutsideDays?, + ~showWeekNumber?, + (), + ) + | Client => + ClientImpl.render( + ~mode, + ~selected, + ~onSelect, + ~captionLayout?, + ~reverseYears?, + ~navLayout?, + ~disableNavigation?, + ~hideNavigation?, + ~animate?, + ~fixedWeeks?, + ~footer?, + ~hideWeekdays?, + ~numberOfMonths?, + ~reverseMonths?, + ~pagedNavigation?, + ~showOutsideDays?, + ~showWeekNumber?, + (), + ) + }; diff --git a/dune b/dune index 7666b24..5aceba4 100644 --- a/dune +++ b/dune @@ -6,13 +6,4 @@ (modes melange) (libraries reason-react melange.js) (preprocess - (pps melange.ppx reason-react-ppx))) - -(library - (name react_day_picker_native) - (public_name reason-react-day-picker.native) - (wrapped false) - (modes native) - (modules ReactDayPickerNative) - (optional) - (libraries server-reason-react.react server-reason-react.js)) + (pps server-reason-react.browser_ppx -js melange.ppx reason-react-ppx))) diff --git a/dune-project b/dune-project index 09950eb..1a3f5fe 100644 --- a/dune-project +++ b/dune-project @@ -16,20 +16,19 @@ (using melange 0.1) (package - (name reason-react-day-picker) - (synopsis "Melange bindings for react-day-picker") - (description "Melange bindings for react-day-picker written in ReasonML") - (depends - ocaml - melange - reason - melange-webapi - ppxlib - reason-react-ppx - reason-react - server-reason-react) - (allow_empty) - (tags - ("melange" "reason" "bindings" "react-day-picker" "react"))) + (name reason-react-day-picker) + (synopsis "Melange bindings for react-day-picker") + (description "Melange bindings for react-day-picker written in ReasonML") + (depends + ocaml + melange + reason + melange-webapi + reason-react-ppx + reason-react + server-reason-react) + (allow_empty) + (tags + ("melange" "reason" "bindings" "react-day-picker" "react"))) ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html diff --git a/example/README.md b/example/README.md index 4b8bee2..08e026c 100644 --- a/example/README.md +++ b/example/README.md @@ -6,9 +6,9 @@ This folder follows the same split used by `server-reason-react` demos: - `example/js/`: Melange renderer using `ReactDOMServer.renderToString` - `example/native/`: native renderer using `ReactDOM.renderToString` -Both renderers use the same props and should produce matching HTML for the same -fixture values in `example/shared/SharedFixture.re`, including `footer` via -`React.string(...)`. +Both renderers use the same `ReactDayPicker` name and props, and should +produce matching HTML for the same fixture values in +`example/shared/SharedFixture.re`, including `footer` via `React.string(...)`. ### Run the native renderer diff --git a/example/js/JsRenderer.re b/example/js/JsRenderer.re index 800519f..885b9e2 100644 --- a/example/js/JsRenderer.re +++ b/example/js/JsRenderer.re @@ -24,30 +24,26 @@ let navLayout = config.navLayout; let onSelect = `Single((_date: ReactDayPicker.singleDate) => ()); let selected = `Single(Js.Undefined.return(demoDate)); -let dayPicker = { - let props = - ReactDayPicker.makeProps( - ~mode=`Single, - ~onSelect, - ~selected, - ~captionLayout?, - ~reverseYears=config.reverseYears, - ~navLayout?, - ~disableNavigation=config.disableNavigation, - ~hideNavigation=config.hideNavigation, - ~animate=config.animate, - ~fixedWeeks=config.fixedWeeks, - ~footer?, - ~hideWeekdays=config.hideWeekdays, - ~numberOfMonths=config.numberOfMonths, - ~reverseMonths=config.reverseMonths, - ~pagedNavigation=config.pagedNavigation, - ~showOutsideDays=config.showOutsideDays, - ~showWeekNumber=config.showWeekNumber, - (), - ); - React.createElement(ReactDayPicker.make, props); -}; +let dayPicker = + ; let rendered = ReactDOMServer.renderToString(dayPicker); diff --git a/example/native/NativeRenderer.re b/example/native/NativeRenderer.re index 2d613d7..3819ecc 100644 --- a/example/native/NativeRenderer.re +++ b/example/native/NativeRenderer.re @@ -20,27 +20,28 @@ let footer = let captionLayout = config.captionLayout; let navLayout = config.navLayout; +let onSelect = `Single((_date: ReactDayPicker.singleDate) => ()); let dayPicker = - ReactDayPickerNative.make( - ~mode=`Single, - ~selected=`Single(Js.Undefined.fromOption(Some(demoDate))), - ~captionLayout?, - ~reverseYears=config.reverseYears, - ~navLayout?, - ~disableNavigation=config.disableNavigation, - ~hideNavigation=config.hideNavigation, - ~animate=config.animate, - ~fixedWeeks=config.fixedWeeks, - ~footer?, - ~hideWeekdays=config.hideWeekdays, - ~numberOfMonths=config.numberOfMonths, - ~reverseMonths=config.reverseMonths, - ~pagedNavigation=config.pagedNavigation, - ~showOutsideDays=config.showOutsideDays, - ~showWeekNumber=config.showWeekNumber, - (), - ); + ; let rendered = ReactDOM.renderToString(dayPicker); diff --git a/example/native/dune b/example/native/dune index 62d776a..546c4bf 100644 --- a/example/native/dune +++ b/example/native/dune @@ -2,6 +2,8 @@ (executable (name NativeRenderer) + (preprocess + (pps server-reason-react.ppx)) (libraries reason-react-day-picker.native server-reason-react.react diff --git a/native/dune b/native/dune new file mode 100644 index 0000000..69ae1db --- /dev/null +++ b/native/dune @@ -0,0 +1,13 @@ +(copy_files# "../ReactDayPicker.re") +(copy_files# "../ReactDayPickerNative.re") + +(library + (name react_day_picker_native) + (public_name reason-react-day-picker.native) + (wrapped false) + (modes native) + (modules ReactDayPicker ReactDayPickerNative) + (optional) + (preprocess + (pps server-reason-react.browser_ppx server-reason-react.ppx)) + (libraries server-reason-react.react server-reason-react.js)) diff --git a/reason-react-day-picker.opam b/reason-react-day-picker.opam index 4b1a63f..ccb94ff 100644 --- a/reason-react-day-picker.opam +++ b/reason-react-day-picker.opam @@ -15,9 +15,9 @@ depends: [ "melange" "reason" "melange-webapi" - "ppxlib" "reason-react-ppx" "reason-react" + "server-reason-react" "odoc" {with-doc} ] build: [ From e6a97247e3e2e767c1e301fd91114e3558ce53df Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Sat, 21 Mar 2026 14:59:53 -0400 Subject: [PATCH 03/12] fix native build for pinned server-reason-react Pin server-reason-react to the git source and update the native date construction/docs so this package still builds against that runtime API. --- README.md | 4 +- ReactDayPickerNative.re | 857 ++++++++++++++++++-------- dune-project | 5 + example/native/NativeRenderer.re | 3 +- reason-react-day-picker.opam | 3 + reason-react-day-picker.opam.template | 3 + 6 files changed, 632 insertions(+), 243 deletions(-) create mode 100644 reason-react-day-picker.opam.template diff --git a/README.md b/README.md index 63bfcfc..4080400 100644 --- a/README.md +++ b/README.md @@ -20,9 +20,9 @@ Add `reason-react-day-picker` to `libraries` dune stanza: The native version is optional and only builds when `server-reason-react` is installed. -1. Install server-reason-react: +1. Pin and install `server-reason-react` from git: ```bash -opam install server-reason-react +opam pin add server-reason-react git+https://github.com/ml-in-barcelona/server-reason-react.git ``` 2. Add `reason-react-day-picker.native` to your libraries: diff --git a/ReactDayPickerNative.re b/ReactDayPickerNative.re index 785c74b..dd2a6eb 100644 --- a/ReactDayPickerNative.re +++ b/ReactDayPickerNative.re @@ -139,28 +139,45 @@ type calendarWeek = { weekNumber: int, }; -let dateYear = (date: Js.Date.t): int => int_of_float(Js.Date.getFullYear(date)); -let dateMonth = (date: Js.Date.t): int => int_of_float(Js.Date.getMonth(date)); +let dateYear = (date: Js.Date.t): int => + int_of_float(Js.Date.getFullYear(date)); +let dateMonth = (date: Js.Date.t): int => + int_of_float(Js.Date.getMonth(date)); let dateDay = (date: Js.Date.t): int => int_of_float(Js.Date.getDate(date)); -let dayOfWeek = (date: Js.Date.t): int => int_of_float(Js.Date.getDay(date)); - -let monthNames = [|"January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"|]; +let dayOfWeek = (date: Js.Date.t): int => + int_of_float(Js.Date.getDay(date)); + +let monthNames = [| + "January", + "February", + "March", + "April", + "May", + "June", + "July", + "August", + "September", + "October", + "November", + "December", +|]; let getMonthName = (index: int): string => if (index >= 0 && index < Array.length(monthNames)) { - Array.get(monthNames, index); + monthNames[index]; } else { ""; }; let newDate = (~year: int, ~month: int, ~day: int): Js.Date.t => { - Js.Date.makeWithYMDHMS( + Js.Date.make( ~year=float_of_int(year), ~month=float_of_int(month), ~date=float_of_int(day), ~hours=12., ~minutes=0., ~seconds=0., + (), ); }; @@ -168,13 +185,18 @@ let setMonth = (date: Js.Date.t, month: int): Js.Date.t => { newDate(~year=dateYear(date), ~month, ~day=dateDay(date)); }; -let startOfMonth = (date: Js.Date.t): Js.Date.t => newDate(~year=dateYear(date), ~month=dateMonth(date), ~day=1); +let startOfMonth = (date: Js.Date.t): Js.Date.t => + newDate(~year=dateYear(date), ~month=dateMonth(date), ~day=1); -let isBefore = (a: Js.Date.t, b: Js.Date.t): bool => Js.Date.getTime(a) < Js.Date.getTime(b); -let isAfter = (a: Js.Date.t, b: Js.Date.t): bool => Js.Date.getTime(a) > Js.Date.getTime(b); +let isBefore = (a: Js.Date.t, b: Js.Date.t): bool => + Js.Date.getTime(a) < Js.Date.getTime(b); +let isAfter = (a: Js.Date.t, b: Js.Date.t): bool => + Js.Date.getTime(a) > Js.Date.getTime(b); let isSameDay = (a: Js.Date.t, b: Js.Date.t): bool => { - dateYear(a) == dateYear(b) && dateMonth(a) == dateMonth(b) && dateDay(a) == dateDay(b); + dateYear(a) == dateYear(b) + && dateMonth(a) == dateMonth(b) + && dateDay(a) == dateDay(b); }; let daysInMonth = (year: int, month: int): int => { @@ -203,7 +225,7 @@ let daysInMonth = (year: int, month: int): int => { let formatMonthYear = (date: Js.Date.t): string => { let monthName = if (dateMonth(date) >= 0 && dateMonth(date) < Array.length(monthNames)) { - Some(Array.get(monthNames, dateMonth(date))); + Some(monthNames[dateMonth(date)]); } else { None; }; @@ -213,35 +235,59 @@ let formatMonthYear = (date: Js.Date.t): string => { }; }; -let formatDayNumber = (date: Js.Date.t): string => string_of_int(dateDay(date)); +let formatDayNumber = (date: Js.Date.t): string => + string_of_int(dateDay(date)); let formatISODate = (date: Js.Date.t): string => { let y = dateYear(date); let m = dateMonth(date) + 1; let d = dateDay(date); - let mm = if (m < 10) { "0" ++ string_of_int(m) } else { string_of_int(m) }; - let dd = if (d < 10) { "0" ++ string_of_int(d) } else { string_of_int(d) }; + let mm = + if (m < 10) { + "0" ++ string_of_int(m); + } else { + string_of_int(m); + }; + let dd = + if (d < 10) { + "0" ++ string_of_int(d); + } else { + string_of_int(d); + }; string_of_int(y) ++ "-" ++ mm ++ "-" ++ dd; }; let formatYearMonth = (date: Js.Date.t): string => { let y = dateYear(date); let m = dateMonth(date) + 1; - let mm = if (m < 10) { "0" ++ string_of_int(m) } else { string_of_int(m) }; + let mm = + if (m < 10) { + "0" ++ string_of_int(m); + } else { + string_of_int(m); + }; string_of_int(y) ++ "-" ++ mm; }; let weekdayShort = [|"Su", "Mo", "Tu", "We", "Th", "Fr", "Sa"|]; -let weekdayLabel = [|"Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"|]; +let weekdayLabel = [| + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +|]; let getWeekdayName = (index: int): string => if (index >= 0 && index < Array.length(weekdayLabel)) { - Array.get(weekdayLabel, index); + weekdayLabel[index]; } else { ""; }; let getWeekdayShort = (index: int): string => if (index >= 0 && index < Array.length(weekdayShort)) { - Array.get(weekdayShort, index); + weekdayShort[index]; } else { ""; }; @@ -261,10 +307,12 @@ let getWeekNumber = (date: Js.Date.t): int => { let yearVal = dateYear(date); let firstOfYear = newDate(~year=yearVal, ~month=0, ~day=1); let firstWeekday = dayOfWeek(firstOfYear); - ((getDayOfYear(date) - 1 + firstWeekday) / 7) + 1; + (getDayOfYear(date) - 1 + firstWeekday) / 7 + 1; }; -let buildMonthWeeks = (monthDate: Js.Date.t, ~weekStartsOn: int=0, ~fixedWeeks: bool=false, ()): array(calendarWeek) => { +let buildMonthWeeks = + (monthDate: Js.Date.t, ~weekStartsOn: int=0, ~fixedWeeks: bool=false, ()) + : array(calendarWeek) => { let yearVal = dateYear(monthDate); let monthVal = dateMonth(monthDate); let firstDayOfMonth = newDate(~year=yearVal, ~month=monthVal, ~day=1); @@ -277,13 +325,23 @@ let buildMonthWeeks = (monthDate: Js.Date.t, ~weekStartsOn: int=0, ~fixedWeeks: (firstDayWeekday + lastDay + 6) / 7; }; - let prevMonth = if (monthVal == 0) { 11 } else { monthVal - 1 }; - let prevYear = if (monthVal == 0) { yearVal - 1 } else { yearVal }; + let prevMonth = + if (monthVal == 0) { + 11; + } else { + monthVal - 1; + }; + let prevYear = + if (monthVal == 0) { + yearVal - 1; + } else { + yearVal; + }; let prevMonthDays = daysInMonth(prevYear, prevMonth); - let weeks = ref([]); - let dayCounter = ref(1); - let nextMonthDay = ref(1); + let weeks = ref([]); + let dayCounter = ref(1); + let nextMonthDay = ref(1); for (weekIndex in 0 to totalWeeks - 1) { let weekDays = ref([]); @@ -291,19 +349,40 @@ let buildMonthWeeks = (monthDate: Js.Date.t, ~weekStartsOn: int=0, ~fixedWeeks: let dayInfo = if (weekIndex == 0 && dayIndex < firstDayWeekday) { let prevDay = prevMonthDays - firstDayWeekday + dayIndex + 1; - {date: newDate(~year=prevYear, ~month=prevMonth, ~day=prevDay), isOutside: true}; + { + date: newDate(~year=prevYear, ~month=prevMonth, ~day=prevDay), + isOutside: true, + }; } else if (dayCounter^ > lastDay) { - let nextMonth = if (monthVal == 11) { 0 } else { monthVal + 1 }; - let nextYear = if (monthVal == 11) { yearVal + 1 } else { yearVal }; - let dayObj = newDate(~year=nextYear, ~month=nextMonth, ~day=nextMonthDay^); + let nextMonth = + if (monthVal == 11) { + 0; + } else { + monthVal + 1; + }; + let nextYear = + if (monthVal == 11) { + yearVal + 1; + } else { + yearVal; + }; + let dayObj = + newDate(~year=nextYear, ~month=nextMonth, ~day=nextMonthDay^); nextMonthDay := nextMonthDay^ + 1; - {date: dayObj, isOutside: true}; + { + date: dayObj, + isOutside: true, + }; } else { - let dayObj = newDate(~year=yearVal, ~month=monthVal, ~day=dayCounter^); + let dayObj = + newDate(~year=yearVal, ~month=monthVal, ~day=dayCounter^); dayCounter := dayCounter^ + 1; - {date: dayObj, isOutside: false}; + { + date: dayObj, + isOutside: false, + }; }; - weekDays := [dayInfo, ...weekDays^]; + weekDays := [dayInfo, ...weekDays^]; }; let reversedWeekDays = List.rev(weekDays^); @@ -311,11 +390,21 @@ let buildMonthWeeks = (monthDate: Js.Date.t, ~weekStartsOn: int=0, ~fixedWeeks: let firstDate = switch (reversedWeekDays) { | [value, ..._] => value - | [] => {date: newDate(~year=yearVal, ~month=monthVal, ~day=1), isOutside: false} + | [] => { + date: newDate(~year=yearVal, ~month=monthVal, ~day=1), + isOutside: false, + } }; let weekNum = getWeekNumber(firstDate.date); - weeks := [{days: weekDaysArray, weekNumber: weekNum}, ...weeks^]; + weeks := + [ + { + days: weekDaysArray, + weekNumber: weekNum, + }, + ...weeks^, + ]; }; Array.of_list(List.rev(weeks^)); @@ -331,16 +420,20 @@ let isDateSelected = (date: Js.Date.t, selected: option(selected)): bool => { } | Some(`Multiple(optionDates)) => switch (Js.Undefined.toOption(optionDates)) { - | Some(values) => values |> Array.exists((value) => isSameDay(date, value)) + | Some(values) => values |> Array.exists(value => isSameDay(date, value)) | None => false } | Some(`Range(optionRange)) => switch (Js.Undefined.toOption(optionRange)) { | None => false | Some(rangeValue) => - switch (Js.Undefined.toOption(rangeValue.from), Js.Undefined.toOption(rangeValue.to_)) { + switch ( + Js.Undefined.toOption(rangeValue.from), + Js.Undefined.toOption(rangeValue.to_), + ) { | (Some(from), Some(to_)) => - (isAfter(date, from) || isSameDay(date, from)) && (isBefore(date, to_) || isSameDay(date, to_)) + (isAfter(date, from) || isSameDay(date, from)) + && (isBefore(date, to_) || isSameDay(date, to_)) | (Some(from), None) => isSameDay(date, from) | (None, Some(to_)) => isSameDay(date, to_) | (None, None) => false @@ -355,7 +448,8 @@ type rangePosition = | RangeMiddle | RangeEnd; -let getRangePosition = (date: Js.Date.t, selected: option(selected)): rangePosition => { +let getRangePosition = + (date: Js.Date.t, selected: option(selected)): rangePosition => { switch (selected) { | Some(`Range(optionRange)) => switch (Js.Undefined.toOption(optionRange)) { @@ -375,7 +469,10 @@ let getRangePosition = (date: Js.Date.t, selected: option(selected)): rangePosit } else if (isEnd) { RangeEnd; } else { - switch (Js.Undefined.toOption(rangeValue.from), Js.Undefined.toOption(rangeValue.to_)) { + switch ( + Js.Undefined.toOption(rangeValue.from), + Js.Undefined.toOption(rangeValue.to_), + ) { | (Some(start), Some(stop)) => if (isAfter(date, start) && isBefore(date, stop)) { RangeMiddle; @@ -384,7 +481,7 @@ let getRangePosition = (date: Js.Date.t, selected: option(selected)): rangePosit } | _ => NoRange }; - } + }; | None => NoRange } | _ => NoRange @@ -393,7 +490,12 @@ let getRangePosition = (date: Js.Date.t, selected: option(selected)): rangePosit let joinClassList = (values: array(string)): string => { Array.fold_left( - (acc, value) => if (String.length(acc) == 0) { value } else { acc ++ " " ++ value }, + (acc, value) => + if (String.length(acc) == 0) { + value; + } else { + acc ++ " " ++ value; + }, "", values, ); @@ -414,11 +516,26 @@ let ordinalSuffix = (day: int): string => { }; let formatAriaDayLabel = (date: Js.Date.t, ~isToday: bool): string => { - let prefix = if (isToday) { "Today, " } else { "" }; - prefix ++ getWeekdayName(dayOfWeek(date)) ++ ", " ++ getMonthName(dateMonth(date)) ++ " " ++ string_of_int(dateDay(date)) ++ ordinalSuffix(dateDay(date)) ++ ", " ++ string_of_int(dateYear(date)); + let prefix = if (isToday) {"Today, "} else {""}; + prefix + ++ getWeekdayName(dayOfWeek(date)) + ++ ", " + ++ getMonthName(dateMonth(date)) + ++ " " + ++ string_of_int(dateDay(date)) + ++ ordinalSuffix(dateDay(date)) + ++ ", " + ++ string_of_int(dateYear(date)); }; -let getDayClasses = (day: calendarDay, selected: option(selected), today: Js.Date.t, showOutsideDays: bool): string => { +let getDayClasses = + ( + day: calendarDay, + selected: option(selected), + today: Js.Date.t, + showOutsideDays: bool, + ) + : string => { let modifiers = ref([]); if (isSameDay(day.date, today)) { modifiers := List.concat([modifiers^, [classNames.dayToday]]); @@ -433,9 +550,12 @@ let getDayClasses = (day: calendarDay, selected: option(selected), today: Js.Dat modifiers := List.concat([modifiers^, [classNames.dayOutside]]); }; switch (getRangePosition(day.date, selected)) { - | RangeStart => modifiers := List.concat([modifiers^, [classNames.dayRangeStart]]) - | RangeMiddle => modifiers := List.concat([modifiers^, [classNames.dayRangeMiddle]]) - | RangeEnd => modifiers := List.concat([modifiers^, [classNames.dayRangeEnd]]) + | RangeStart => + modifiers := List.concat([modifiers^, [classNames.dayRangeStart]]) + | RangeMiddle => + modifiers := List.concat([modifiers^, [classNames.dayRangeMiddle]]) + | RangeEnd => + modifiers := List.concat([modifiers^, [classNames.dayRangeEnd]]) | NoRange => () }; @@ -448,15 +568,25 @@ let getDayClasses = (day: calendarDay, selected: option(selected), today: Js.Dat }; }; -let stringProp = (name: string, jsxName: string, value: string): React.JSX.prop => React.JSX.string(name, jsxName, value); -let intProp = (name: string, jsxName: string, value: int): React.JSX.prop => React.JSX.int(name, jsxName, value); -let boolProp = (name: string, jsxName: string, value: bool): React.JSX.prop => React.JSX.bool(name, jsxName, value); -let styleProp = (value: list((string, string, string))): React.JSX.prop => React.JSX.style(value); -let classNameProp = (value: string): React.JSX.prop => React.JSX.string("class", "className", value); -let roleProp = (value: string): React.JSX.prop => React.JSX.string("role", "role", value); -let scopeProp = (value: string): React.JSX.prop => React.JSX.string("scope", "scope", value); -let ariaLabelProp = (value: string): React.JSX.prop => stringProp("aria-label", "ariaLabel", value); -let ariaHiddenProp = (value: string): React.JSX.prop => stringProp("aria-hidden", "ariaHidden", value); +let stringProp = + (name: string, jsxName: string, value: string): React.JSX.prop => + React.JSX.string(name, jsxName, value); +let intProp = (name: string, jsxName: string, value: int): React.JSX.prop => + React.JSX.int(name, jsxName, value); +let boolProp = (name: string, jsxName: string, value: bool): React.JSX.prop => + React.JSX.bool(name, jsxName, value); +let styleProp = (value: list((string, string, string))): React.JSX.prop => + React.JSX.style(value); +let classNameProp = (value: string): React.JSX.prop => + React.JSX.string("class", "className", value); +let roleProp = (value: string): React.JSX.prop => + React.JSX.string("role", "role", value); +let scopeProp = (value: string): React.JSX.prop => + React.JSX.string("scope", "scope", value); +let ariaLabelProp = (value: string): React.JSX.prop => + stringProp("aria-label", "ariaLabel", value); +let ariaHiddenProp = (value: string): React.JSX.prop => + stringProp("aria-hidden", "ariaHidden", value); let element = (~key=?, ~props=[], tag, children) => React.createElementWithKey(~key?, tag, props, children); @@ -464,67 +594,110 @@ let element = (~key=?, ~props=[], tag, children) => let renderCaptionChevron = () => element( "svg", - ~props=[classNameProp(classNames.chevron), intProp("width", "width", 18), intProp("height", "height", 18), stringProp("viewBox", "viewBox", "0 0 24 24")], - [element("polygon", ~props=[stringProp("points", "points", "6.77 8 12.5 13.57 18.24 8 20 9.72 12.5 17 5 9.72")], [])], + ~props=[ + classNameProp(classNames.chevron), + intProp("width", "width", 18), + intProp("height", "height", 18), + stringProp("viewBox", "viewBox", "0 0 24 24"), + ], + [ + element( + "polygon", + ~props=[ + stringProp( + "points", + "points", + "6.77 8 12.5 13.57 18.24 8 20 9.72 12.5 17 5 9.72", + ), + ], + [], + ), + ], ); -let renderMonthOption = (monthIndex: int, selectedMonth: int) => - { +let renderMonthOption = (monthIndex: int, selectedMonth: int) => { let props = List.concat([ [stringProp("value", "value", string_of_int(monthIndex))], - monthIndex == selectedMonth ? [stringProp("selected", "selected", "")] : [], + monthIndex == selectedMonth + ? [stringProp("selected", "selected", "")] : [], ]); - element( - "option", - ~props, - [React.string(getMonthName(monthIndex))], - ); - }; + element("option", ~props, [React.string(getMonthName(monthIndex))]); +}; -let renderYearOption = (yearValue: int, selectedYear: int) => - { +let renderYearOption = (yearValue: int, selectedYear: int) => { let props = List.concat([ [stringProp("value", "value", string_of_int(yearValue))], - yearValue == selectedYear ? [stringProp("selected", "selected", "")] : [], + yearValue == selectedYear + ? [stringProp("selected", "selected", "")] : [], ]); - element( - "option", - ~props, - [React.string(string_of_int(yearValue))], - ); - }; + element("option", ~props, [React.string(string_of_int(yearValue))]); +}; -let renderDropdownRoot = (~disabled: bool, ~selectClassName: string, ~ariaLabel: string, ~selectChildren: array(React.element), ~displayLabel: string) => +let renderDropdownRoot = + ( + ~disabled: bool, + ~selectClassName: string, + ~ariaLabel: string, + ~selectChildren: array(React.element), + ~displayLabel: string, + ) => element( "span", - ~props=[stringProp("data-disabled", "dataDisabled", disabled ? "true" : "false"), classNameProp(classNames.dropdownRoot)], + ~props=[ + stringProp( + "data-disabled", + "dataDisabled", + disabled ? "true" : "false", + ), + classNameProp(classNames.dropdownRoot), + ], [ element( "select", - ~props=List.concat([[classNameProp(selectClassName), ariaLabelProp(ariaLabel)], disabled ? [boolProp("disabled", "disabled", true)] : []]), + ~props= + List.concat([ + [classNameProp(selectClassName), ariaLabelProp(ariaLabel)], + disabled ? [boolProp("disabled", "disabled", true)] : [], + ]), [React.array(selectChildren)], ), element( "span", - ~props=[classNameProp(classNames.captionLabel), ariaHiddenProp("true")], + ~props=[ + classNameProp(classNames.captionLabel), + ariaHiddenProp("true"), + ], [React.string(displayLabel), renderCaptionChevron()], ), ], ); -let renderCaptionDropdowns = (~captionLayout: captionLayout, ~monthDate: Js.Date.t, ~navigationDisabled: bool, ~todayYear: int, ~reverseYears: bool) => { +let renderCaptionDropdowns = + ( + ~captionLayout: captionLayout, + ~monthDate: Js.Date.t, + ~navigationDisabled: bool, + ~todayYear: int, + ~reverseYears: bool, + ) => { let selectedMonth = dateMonth(monthDate); let selectedYear = dateYear(monthDate); - let monthOptions = Array.init(12, (monthIndex) => renderMonthOption(monthIndex, selectedMonth)); + let monthOptions = + Array.init(12, monthIndex => + renderMonthOption(monthIndex, selectedMonth) + ); let startYear = todayYear - 100; let yearCount = todayYear - startYear + 1; let yearOptions = - Array.init(yearCount, (index) => { - let yearValue = reverseYears ? todayYear - index : startYear + index; - renderYearOption(yearValue, selectedYear); - }); + Array.init( + yearCount, + index => { + let yearValue = reverseYears ? todayYear - index : startYear + index; + renderYearOption(yearValue, selectedYear); + }, + ); let liveRegion = element( @@ -556,14 +729,16 @@ let renderCaptionDropdowns = (~captionLayout: captionLayout, ~monthDate: Js.Date [ renderDropdownRoot( ~disabled=navigationDisabled, - ~selectClassName=classNames.dropdown ++ " " ++ classNames.monthsDropdown, + ~selectClassName= + classNames.dropdown ++ " " ++ classNames.monthsDropdown, ~ariaLabel="Choose the Month", ~selectChildren=monthOptions, ~displayLabel=getMonthName(selectedMonth), ), renderDropdownRoot( ~disabled=navigationDisabled, - ~selectClassName=classNames.dropdown ++ " " ++ classNames.yearsDropdown, + ~selectClassName= + classNames.dropdown ++ " " ++ classNames.yearsDropdown, ~ariaLabel="Choose the Year", ~selectChildren=yearOptions, ~displayLabel=string_of_int(selectedYear), @@ -578,7 +753,8 @@ let renderCaptionDropdowns = (~captionLayout: captionLayout, ~monthDate: Js.Date [ renderDropdownRoot( ~disabled=navigationDisabled, - ~selectClassName=classNames.dropdown ++ " " ++ classNames.monthsDropdown, + ~selectClassName= + classNames.dropdown ++ " " ++ classNames.monthsDropdown, ~ariaLabel="Choose the Month", ~selectChildren=monthOptions, ~displayLabel=getMonthName(selectedMonth), @@ -595,7 +771,8 @@ let renderCaptionDropdowns = (~captionLayout: captionLayout, ~monthDate: Js.Date element("span", [React.string(getMonthName(selectedMonth))]), renderDropdownRoot( ~disabled=navigationDisabled, - ~selectClassName=classNames.dropdown ++ " " ++ classNames.yearsDropdown, + ~selectClassName= + classNames.dropdown ++ " " ++ classNames.yearsDropdown, ~ariaLabel="Choose the Year", ~selectChildren=yearOptions, ~displayLabel=string_of_int(selectedYear), @@ -609,10 +786,14 @@ let renderCaptionDropdowns = (~captionLayout: captionLayout, ~monthDate: Js.Date let renderWeekdayHeader = (showWeekNum: bool, ~animate: bool) => { let weekdayCells = - Array.init(7, (dayIndex) => + Array.init(7, dayIndex => element( "th", - ~props=[ariaLabelProp(getWeekdayName(dayIndex)), classNameProp(classNames.weekday), scopeProp("col")], + ~props=[ + ariaLabelProp(getWeekdayName(dayIndex)), + classNameProp(classNames.weekday), + scopeProp("col"), + ], [React.string(getWeekdayShort(dayIndex))], ) ); @@ -621,7 +802,11 @@ let renderWeekdayHeader = (showWeekNum: bool, ~animate: bool) => { if (showWeekNum) { element( "th", - ~props=[ariaLabelProp("Week Number"), classNameProp(classNames.weekNumberHeader), scopeProp("col")], + ~props=[ + ariaLabelProp("Week Number"), + classNameProp(classNames.weekNumberHeader), + scopeProp("col"), + ], [React.string("")], ); } else { @@ -636,7 +821,14 @@ let renderWeekdayHeader = (showWeekNum: bool, ~animate: bool) => { "tr", ~props= animate - ? [stringProp("data-animated-weekdays", "dataAnimatedWeekdays", "true"), classNameProp(classNames.weekdays)] + ? [ + stringProp( + "data-animated-weekdays", + "dataAnimatedWeekdays", + "true", + ), + classNameProp(classNames.weekdays), + ] : [classNameProp(classNames.weekdays)], [weekNumberHeader, React.array(weekdayCells)], ), @@ -644,12 +836,24 @@ let renderWeekdayHeader = (showWeekNum: bool, ~animate: bool) => { ); }; -let renderWeekRow = (week: calendarWeek, ~showWeekNum: bool, selected: option(selected), today: Js.Date.t, showOutsideDays: bool) => { +let renderWeekRow = + ( + week: calendarWeek, + ~showWeekNum: bool, + selected: option(selected), + today: Js.Date.t, + showOutsideDays: bool, + ) => { let weekNumberCell = if (showWeekNum) { element( "th", - ~props=[ariaLabelProp("Week " ++ string_of_int(week.weekNumber)), classNameProp(classNames.weekNumber), scopeProp("row"), roleProp("rowheader")], + ~props=[ + ariaLabelProp("Week " ++ string_of_int(week.weekNumber)), + classNameProp(classNames.weekNumber), + scopeProp("row"), + roleProp("rowheader"), + ], [React.string(string_of_int(week.weekNumber))], ); } else { @@ -659,179 +863,307 @@ let renderWeekRow = (week: calendarWeek, ~showWeekNum: bool, selected: option(se let dayCells = week.days |> Array.map((day: calendarDay) => { - let cellClassName = getDayClasses(day, selected, today, showOutsideDays); - let dayIsToday = isSameDay(day.date, today); - let hasContent = showOutsideDays || !day.isOutside; - let tdProps = ref([classNameProp(cellClassName), roleProp("gridcell"), stringProp("data-day", "dataDay", formatISODate(day.date))]); - if (day.isOutside) { - tdProps := - List.concat([ - tdProps^, - showOutsideDays - ? [stringProp("data-month", "dataMonth", formatYearMonth(day.date)), stringProp("data-outside", "dataOutside", "true")] - : [ - stringProp("data-month", "dataMonth", formatYearMonth(day.date)), - stringProp("data-hidden", "dataHidden", "true"), - stringProp("data-outside", "dataOutside", "true"), - ], - ]); - }; - if (dayIsToday) { - tdProps := List.concat([tdProps^, [stringProp("data-today", "dataToday", "true")]]); - }; - - let content = - if (hasContent) { - element( - "button", - ~props=[ - classNameProp(classNames.dayButton), - stringProp("type", "type", "button"), - intProp("tabindex", "tabIndex", dayIsToday ? 0 : -1), - ariaLabelProp(formatAriaDayLabel(day.date, ~isToday=dayIsToday)), - ], - [React.string(formatDayNumber(day.date))], - ); - } else { - React.string(""); - }; - - element("td", ~props=tdProps^, [content]); - }); + let cellClassName = + getDayClasses(day, selected, today, showOutsideDays); + let dayIsToday = isSameDay(day.date, today); + let hasContent = showOutsideDays || !day.isOutside; + let tdProps = + ref([ + classNameProp(cellClassName), + roleProp("gridcell"), + stringProp("data-day", "dataDay", formatISODate(day.date)), + ]); + if (day.isOutside) { + tdProps := + List.concat([ + tdProps^, + showOutsideDays + ? [ + stringProp( + "data-month", + "dataMonth", + formatYearMonth(day.date), + ), + stringProp("data-outside", "dataOutside", "true"), + ] + : [ + stringProp( + "data-month", + "dataMonth", + formatYearMonth(day.date), + ), + stringProp("data-hidden", "dataHidden", "true"), + stringProp("data-outside", "dataOutside", "true"), + ], + ]); + }; + if (dayIsToday) { + tdProps := + List.concat([ + tdProps^, + [stringProp("data-today", "dataToday", "true")], + ]); + }; + + let content = + if (hasContent) { + element( + "button", + ~props=[ + classNameProp(classNames.dayButton), + stringProp("type", "type", "button"), + intProp("tabindex", "tabIndex", dayIsToday ? 0 : (-1)), + ariaLabelProp( + formatAriaDayLabel(day.date, ~isToday=dayIsToday), + ), + ], + [React.string(formatDayNumber(day.date))], + ); + } else { + React.string(""); + }; + + element("td", ~props=tdProps^, [content]); + }); - element("tr", ~props=[classNameProp(classNames.week)], [weekNumberCell, React.array(dayCells)]); + element( + "tr", + ~props=[classNameProp(classNames.week)], + [weekNumberCell, React.array(dayCells)], + ); }; -let renderMonth = ( - monthIndex: int, - ~showOutsideDays: bool, - ~showWeekNumber: bool, - ~hideWeekdays: bool, - ~fixedWeeks: bool, - ~captionLayout: option(captionLayout), - ~reverseYears: bool, - ~animate: bool, - ~navigationDisabled: bool, - ~embeddedNavigation: option(React.element), - ~todayYear: int, - today: Js.Date.t, - selected: option(selected), -) => { - let monthStart = startOfMonth(today) |> setMonth(_, dateMonth(today) + monthIndex); +let renderMonth = + ( + monthIndex: int, + ~showOutsideDays: bool, + ~showWeekNumber: bool, + ~hideWeekdays: bool, + ~fixedWeeks: bool, + ~captionLayout: option(captionLayout), + ~reverseYears: bool, + ~animate: bool, + ~navigationDisabled: bool, + ~embeddedNavigation: option(React.element), + ~todayYear: int, + today: Js.Date.t, + selected: option(selected), + ) => { + let monthStart = + startOfMonth(today) |> setMonth(_, dateMonth(today) + monthIndex); let weeks = buildMonthWeeks(monthStart, ~weekStartsOn=0, ~fixedWeeks, ()); - let weekdayHeader = hideWeekdays ? React.null : renderWeekdayHeader(showWeekNumber, ~animate); + let weekdayHeader = + hideWeekdays ? React.null : renderWeekdayHeader(showWeekNumber, ~animate); let weekRows = weeks - |> Array.map((week: calendarWeek) => renderWeekRow(week, ~showWeekNum=showWeekNumber, selected, today, showOutsideDays)); + |> Array.map((week: calendarWeek) => + renderWeekRow( + week, + ~showWeekNum=showWeekNumber, + selected, + today, + showOutsideDays, + ) + ); let captionContent = switch (captionLayout) { | Some(`Dropdown as layout) | Some(`DropdownMonths as layout) | Some(`DropdownYears as layout) => - renderCaptionDropdowns(~captionLayout=layout, ~monthDate=monthStart, ~navigationDisabled, ~todayYear, ~reverseYears) + renderCaptionDropdowns( + ~captionLayout=layout, + ~monthDate=monthStart, + ~navigationDisabled, + ~todayYear, + ~reverseYears, + ) | _ => element( "span", - ~props=[classNameProp(classNames.captionLabel), roleProp("status"), stringProp("aria-live", "ariaLive", "polite")], + ~props=[ + classNameProp(classNames.captionLabel), + roleProp("status"), + stringProp("aria-live", "ariaLive", "polite"), + ], [React.string(formatMonthYear(monthStart))], ) }; let captionProps = animate - ? [stringProp("data-animated-caption", "dataAnimatedCaption", "true"), classNameProp(classNames.monthCaption)] + ? [ + stringProp("data-animated-caption", "dataAnimatedCaption", "true"), + classNameProp(classNames.monthCaption), + ] : [classNameProp(classNames.monthCaption)]; let monthProps = animate - ? [stringProp("data-animated-month", "dataAnimatedMonth", "true"), classNameProp(classNames.month)] + ? [ + stringProp("data-animated-month", "dataAnimatedMonth", "true"), + classNameProp(classNames.month), + ] : [classNameProp(classNames.month)]; let weeksProps = animate - ? [stringProp("data-animated-weeks", "dataAnimatedWeeks", "true"), classNameProp(classNames.weeks)] + ? [ + stringProp("data-animated-weeks", "dataAnimatedWeeks", "true"), + classNameProp(classNames.weeks), + ] : [classNameProp(classNames.weeks)]; let monthChildren = switch (embeddedNavigation) { - | Some(navigation) => - [ + | Some(navigation) => [ element("div", ~props=captionProps, [captionContent]), navigation, element( "table", - ~props=[roleProp("grid"), stringProp("aria-multiselectable", "ariaMultiselectable", "false"), ariaLabelProp(formatMonthYear(monthStart)), classNameProp(classNames.monthGrid)], - [weekdayHeader, element("tbody", ~props=weeksProps, [React.array(weekRows)])], + ~props=[ + roleProp("grid"), + stringProp( + "aria-multiselectable", + "ariaMultiselectable", + "false", + ), + ariaLabelProp(formatMonthYear(monthStart)), + classNameProp(classNames.monthGrid), + ], + [ + weekdayHeader, + element("tbody", ~props=weeksProps, [React.array(weekRows)]), + ], ), ] - | None => - [ + | None => [ element("div", ~props=captionProps, [captionContent]), element( "table", - ~props=[roleProp("grid"), stringProp("aria-multiselectable", "ariaMultiselectable", "false"), ariaLabelProp(formatMonthYear(monthStart)), classNameProp(classNames.monthGrid)], - [weekdayHeader, element("tbody", ~props=weeksProps, [React.array(weekRows)])], + ~props=[ + roleProp("grid"), + stringProp( + "aria-multiselectable", + "ariaMultiselectable", + "false", + ), + ariaLabelProp(formatMonthYear(monthStart)), + classNameProp(classNames.monthGrid), + ], + [ + weekdayHeader, + element("tbody", ~props=weeksProps, [React.array(weekRows)]), + ], ), ] }; - element("div", ~key=string_of_int(monthIndex), ~props=monthProps, monthChildren); + element( + "div", + ~key=string_of_int(monthIndex), + ~props=monthProps, + monthChildren, + ); }; let renderNavigation = (~animate: bool, ~navigationDisabled: bool) => { let chevron = (points: string) => element( "svg", - ~props=[classNameProp(classNames.chevron), intProp("width", "width", 24), intProp("height", "height", 24), stringProp("viewBox", "viewBox", "0 0 24 24")], - [element("polygon", ~props=[stringProp("points", "points", points)], [])], + ~props=[ + classNameProp(classNames.chevron), + intProp("width", "width", 24), + intProp("height", "height", 24), + stringProp("viewBox", "viewBox", "0 0 24 24"), + ], + [ + element( + "polygon", + ~props=[stringProp("points", "points", points)], + [], + ), + ], ); element( "nav", ~props= animate - ? [stringProp("data-animated-nav", "dataAnimatedNav", "true"), classNameProp(classNames.nav), ariaLabelProp("Navigation bar")] + ? [ + stringProp("data-animated-nav", "dataAnimatedNav", "true"), + classNameProp(classNames.nav), + ariaLabelProp("Navigation bar"), + ] : [classNameProp(classNames.nav), ariaLabelProp("Navigation bar")], [ element( "button", ~props= navigationDisabled - ? [stringProp("type", "type", "button"), classNameProp(classNames.buttonPrevious), intProp("tabindex", "tabIndex", -1), stringProp("aria-disabled", "ariaDisabled", "true"), ariaLabelProp("Go to the Previous Month")] - : [stringProp("type", "type", "button"), classNameProp(classNames.buttonPrevious), ariaLabelProp("Go to the Previous Month")], - [chevron("16 18.112 9.81111111 12 16 5.87733333 14.0888889 4 6 12 14.0888889 20")], + ? [ + stringProp("type", "type", "button"), + classNameProp(classNames.buttonPrevious), + intProp("tabindex", "tabIndex", -1), + stringProp("aria-disabled", "ariaDisabled", "true"), + ariaLabelProp("Go to the Previous Month"), + ] + : [ + stringProp("type", "type", "button"), + classNameProp(classNames.buttonPrevious), + ariaLabelProp("Go to the Previous Month"), + ], + [ + chevron( + "16 18.112 9.81111111 12 16 5.87733333 14.0888889 4 6 12 14.0888889 20", + ), + ], ), element( "button", ~props= navigationDisabled - ? [stringProp("type", "type", "button"), classNameProp(classNames.buttonNext), intProp("tabindex", "tabIndex", -1), stringProp("aria-disabled", "ariaDisabled", "true"), ariaLabelProp("Go to the Next Month")] - : [stringProp("type", "type", "button"), classNameProp(classNames.buttonNext), ariaLabelProp("Go to the Next Month")], - [chevron("8 18.112 14.18888889 12 8 5.87733333 9.91111111 4 18 12 9.91111111 20")], + ? [ + stringProp("type", "type", "button"), + classNameProp(classNames.buttonNext), + intProp("tabindex", "tabIndex", -1), + stringProp("aria-disabled", "ariaDisabled", "true"), + ariaLabelProp("Go to the Next Month"), + ] + : [ + stringProp("type", "type", "button"), + classNameProp(classNames.buttonNext), + ariaLabelProp("Go to the Next Month"), + ], + [ + chevron( + "8 18.112 14.18888889 12 8 5.87733333 9.91111111 4 18 12 9.91111111 20", + ), + ], ), ], ); }; -let make = ( - ~mode: option(mode)=?, - ~onSelect: option(onSelect)=?, - ~selected: option(selected)=?, - ~captionLayout: option(captionLayout)=?, - ~reverseYears: option(bool)=?, - ~navLayout: option(navLayout)=?, - ~disableNavigation: option(bool)=?, - ~hideNavigation: option(bool)=?, - ~animate: option(bool)=?, - ~fixedWeeks: option(bool)=?, - ~footer: option(reactNode)=?, - ~hideWeekdays: option(bool)=?, - ~numberOfMonths: option(int)=?, - ~reverseMonths: option(bool)=?, - ~pagedNavigation: option(bool)=?, - ~showOutsideDays: option(bool)=?, - ~showWeekNumber: option(bool)=?, - ~key: option(string)=?, - (), -) => { +let make = + ( + ~mode: option(mode)=?, + ~onSelect: option(onSelect)=?, + ~selected: option(selected)=?, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + ~key: option(string)=?, + (), + ) => { ignore(onSelect); ignore(key); let currentMode = @@ -865,26 +1197,31 @@ let make = ( | None => false }; ignore(pagedNavigation); - let showOutside = switch (showOutsideDays) { + let showOutside = + switch (showOutsideDays) { | Some(value) => value | None => false - }; - let showWeekNum = switch (showWeekNumber) { + }; + let showWeekNum = + switch (showWeekNumber) { | Some(value) => value | None => false - }; - let hideWeekdaysValue = switch (hideWeekdays) { + }; + let hideWeekdaysValue = + switch (hideWeekdays) { | Some(value) => value | None => false - }; - let numberOfMonthsValue = switch (numberOfMonths) { + }; + let numberOfMonthsValue = + switch (numberOfMonths) { | Some(value) => value | None => 1 - }; - let reverseMonthsValue = switch (reverseMonths) { + }; + let reverseMonthsValue = + switch (reverseMonths) { | Some(value) => value | None => false - }; + }; let fixedWeeksValue = switch (fixedWeeks) { | Some(value) => value @@ -898,57 +1235,97 @@ let make = ( } else { Some(renderNavigation(~animate=animateValue, ~navigationDisabled)); }; - let monthIndices = Array.init(numberOfMonthsValue, (idx) => if (reverseMonthsValue) { numberOfMonthsValue - idx - 1 } else { idx }); + let monthIndices = + Array.init(numberOfMonthsValue, idx => + if (reverseMonthsValue) { + numberOfMonthsValue - idx - 1; + } else { + idx; + } + ); let monthElements = monthIndices |> Array.mapi((renderIndex, monthIndex) => { - let embeddedNavigation = - if (navLayoutAfter && renderIndex == numberOfMonthsValue - 1) { - navigationElement; - } else { - None; - }; - renderMonth( - monthIndex, - ~showOutsideDays=showOutside, - ~showWeekNumber=showWeekNum, - ~hideWeekdays=hideWeekdaysValue, - ~fixedWeeks=fixedWeeksValue, - ~captionLayout, - ~reverseYears=reverseYearsValue, - ~animate=animateValue, - ~navigationDisabled, - ~embeddedNavigation, - ~todayYear, - today, - selected, - ); - }); + let embeddedNavigation = + if (navLayoutAfter && renderIndex == numberOfMonthsValue - 1) { + navigationElement; + } else { + None; + }; + renderMonth( + monthIndex, + ~showOutsideDays=showOutside, + ~showWeekNumber=showWeekNum, + ~hideWeekdays=hideWeekdaysValue, + ~fixedWeeks=fixedWeeksValue, + ~captionLayout, + ~reverseYears=reverseYearsValue, + ~animate=animateValue, + ~navigationDisabled, + ~embeddedNavigation, + ~todayYear, + today, + selected, + ); + }); let monthsChildren = switch (navigationElement) { - | Some(value) when !navLayoutAfter => [value, React.array(monthElements)] + | Some(value) when !navLayoutAfter => [ + value, + React.array(monthElements), + ] | _ => [React.array(monthElements)] }; let footerElement = switch (footer) { | Some(value) => - element("div", ~props=[classNameProp(classNames.footer), roleProp("status"), stringProp("aria-live", "ariaLive", "polite")], [value]); + element( + "div", + ~props=[ + classNameProp(classNames.footer), + roleProp("status"), + stringProp("aria-live", "ariaLive", "polite"), + ], + [value], + ) | None => React.null }; let rootProps = List.concat([ - [classNameProp(classNames.root), stringProp("lang", "lang", "en-US"), stringProp("data-mode", "dataMode", currentMode)], - numberOfMonthsValue > 1 ? [stringProp("data-multiple-months", "dataMultipleMonths", "true")] : [], - showWeekNum ? [stringProp("data-week-numbers", "dataWeekNumbers", "true")] : [], - navLayoutAfter ? [stringProp("data-nav-layout", "dataNavLayout", navLayoutToString(`After))] : [], + [ + classNameProp(classNames.root), + stringProp("lang", "lang", "en-US"), + stringProp("data-mode", "dataMode", currentMode), + ], + numberOfMonthsValue > 1 + ? [stringProp("data-multiple-months", "dataMultipleMonths", "true")] + : [], + showWeekNum + ? [stringProp("data-week-numbers", "dataWeekNumbers", "true")] : [], + navLayoutAfter + ? [ + stringProp( + "data-nav-layout", + "dataNavLayout", + navLayoutToString(`After), + ), + ] + : [], ]); element( "div", ~props=rootProps, - [element("div", ~props=[classNameProp(classNames.months)], monthsChildren), footerElement], + [ + element( + "div", + ~props=[classNameProp(classNames.months)], + monthsChildren, + ), + footerElement, + ], ); }; diff --git a/dune-project b/dune-project index 1a3f5fe..8a6b3ea 100644 --- a/dune-project +++ b/dune-project @@ -31,4 +31,9 @@ (tags ("melange" "reason" "bindings" "react-day-picker" "react"))) +(pin + (url "git+https://github.com/ml-in-barcelona/server-reason-react.git") + (package + (name server-reason-react))) + ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html diff --git a/example/native/NativeRenderer.re b/example/native/NativeRenderer.re index 3819ecc..b12112e 100644 --- a/example/native/NativeRenderer.re +++ b/example/native/NativeRenderer.re @@ -3,13 +3,14 @@ open SharedFixture; let config = Scenario.current(); let demoDate = - Js.Date.makeWithYMDHMS( + Js.Date.make( ~year=float_of_int(demoYear), ~month=float_of_int(demoMonth), ~date=float_of_int(demoDay), ~hours=12.0, ~minutes=0.0, ~seconds=0.0, + (), ); let footer = diff --git a/reason-react-day-picker.opam b/reason-react-day-picker.opam index ccb94ff..3ad9311 100644 --- a/reason-react-day-picker.opam +++ b/reason-react-day-picker.opam @@ -37,3 +37,6 @@ build: [ dev-repo: "git+https://github.com/Software-Deployed/reason-react-day-picker.git" x-maintenance-intent: ["(latest)"] +pin-depends: [ + [ "server-reason-react" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] +] diff --git a/reason-react-day-picker.opam.template b/reason-react-day-picker.opam.template new file mode 100644 index 0000000..6db865d --- /dev/null +++ b/reason-react-day-picker.opam.template @@ -0,0 +1,3 @@ +pin-depends: [ + [ "server-reason-react" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] +] From 2bfd6f9f7682a42bfaca249e473509d116783855 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Sat, 21 Mar 2026 19:18:46 -0400 Subject: [PATCH 04/12] temp workaround --- README.md | 19 +++-- ReactDayPicker.re | 38 ++++----- ReactDayPickerNative.re | 102 ++++++++++++++---------- dune-project | 17 ++-- example/README.md | 4 +- example/js/JsRenderer.re | 49 +----------- example/native/NativeRenderer.re | 48 +----------- example/shared/ExampleDayPickers.re | 109 ++++++++++++++++++++++++++ example/shared/SharedFixture.re | 4 +- reason-react-day-picker.opam | 2 +- reason-react-day-picker.opam.template | 2 +- 11 files changed, 215 insertions(+), 179 deletions(-) create mode 100644 example/shared/ExampleDayPickers.re diff --git a/README.md b/README.md index 4080400..aa903b4 100644 --- a/README.md +++ b/README.md @@ -40,12 +40,10 @@ let calendar = { @@ -77,6 +75,10 @@ let calendar = The native package now exposes the same `ReactDayPicker` component name as the Melange package, so the same JSX can render on both targets. +Use `ReactDayPicker.defined(...)` for shared `Js.Undefined.t(...)` values. It +avoids the native `Js.Undefined.return` float-representation trap when working +with `Js.Date.t`. + ```reason let today = Js.Date.make(); @@ -84,7 +86,7 @@ let calendar = ())} - selected={`Single(Js.Undefined.return(today))} + selected={`Single(ReactDayPicker.defined(today))} numberOfMonths=1 showOutsideDays=true />; @@ -96,7 +98,8 @@ let html = ReactDOM.renderToString(calendar); ### Example parity check This repo includes a small universal example in `example/` that mirrors the -layout used by `server-reason-react` demos: +layout used by `server-reason-react` demos. It renders both a `mode="single"` +picker and a `mode="range"` picker with the same shared props on JS and native: ```bash dune exec ./example/native/NativeRenderer.exe diff --git a/ReactDayPicker.re b/ReactDayPicker.re index d4757ee..1984008 100644 --- a/ReactDayPicker.re +++ b/ReactDayPicker.re @@ -50,6 +50,12 @@ type onSelect = [ | `Range(rangeDate => unit) ]; +let defined = (value: 'a): Js.Undefined.t('a) => + switch%platform (Runtime.platform) { + | Server => Js.Undefined.fromOption(Some(value)) + | Client => Js.Undefined.return(value) + }; + [@platform js] module ClientImpl = { type jsDateRange = { @@ -59,17 +65,10 @@ module ClientImpl = { type jsRangeDate = Js.Undefined.t(jsDateRange); - type jsSelected = [ - | `Single(singleDate) - | `Multiple(multipleDate) - | `Range(jsRangeDate) - ]; + /* react-day-picker expects the raw value, not a variant wrapper */ + type jsSelected = Js.Json.t; - type jsOnSelect = [ - | `Single(singleDate => unit) - | `Multiple(multipleDate => unit) - | `Range(jsRangeDate => unit) - ]; + type jsOnSelect = Js.Json.t; let emptyRangeDate: jsRangeDate = Js.Undefined.fromOption(None); @@ -91,23 +90,24 @@ module ClientImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - Js.Undefined.return(nextValue) + defined(nextValue) | None => Js.Undefined.fromOption(None) }; + /* Convert our polymorphic variant to the raw value that react-day-picker expects */ let toJsSelected = (value: selected): jsSelected => switch (value) { - | `Single(date) => `Single(date) - | `Multiple(dates) => `Multiple(dates) - | `Range(rangeValue) => `Range(toJsRangeDate(rangeValue)) + | `Single(date) => Obj.magic(date) + | `Multiple(dates) => Obj.magic(dates) + | `Range(rangeValue) => Obj.magic(toJsRangeDate(rangeValue)) }; let toJsOnSelect = (value: onSelect): jsOnSelect => switch (value) { - | `Single(callback) => `Single(callback) - | `Multiple(callback) => `Multiple(callback) + | `Single(callback) => Obj.magic(callback) + | `Multiple(callback) => Obj.magic(callback) | `Range(callback) => - `Range((dates: jsRangeDate) => callback(fromJsRangeDate(dates))) + Obj.magic((dates: jsRangeDate) => callback(fromJsRangeDate(dates))) }; [@mel.obj] @@ -264,7 +264,7 @@ module ServerImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - Js.Undefined.return(nextValue) + defined(nextValue) | None => emptyRangeDate }; @@ -275,7 +275,7 @@ module ServerImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - Js.Undefined.return(nextValue) + defined(nextValue) | None => Js.Undefined.fromOption(None) }; diff --git a/ReactDayPickerNative.re b/ReactDayPickerNative.re index dd2a6eb..2ab6830 100644 --- a/ReactDayPickerNative.re +++ b/ReactDayPickerNative.re @@ -446,7 +446,8 @@ type rangePosition = | NoRange | RangeStart | RangeMiddle - | RangeEnd; + | RangeEnd + | RangeStartAndEnd; let getRangePosition = (date: Js.Date.t, selected: option(selected)): rangePosition => { @@ -454,33 +455,24 @@ let getRangePosition = | Some(`Range(optionRange)) => switch (Js.Undefined.toOption(optionRange)) { | Some(rangeValue) => - let isStart = - switch (Js.Undefined.toOption(rangeValue.from)) { - | Some(value) => isSameDay(date, value) - | None => false - }; - let isEnd = - switch (Js.Undefined.toOption(rangeValue.to_)) { - | Some(value) => isSameDay(date, value) - | None => false - }; - if (isStart) { - RangeStart; - } else if (isEnd) { - RangeEnd; - } else { - switch ( - Js.Undefined.toOption(rangeValue.from), - Js.Undefined.toOption(rangeValue.to_), - ) { - | (Some(start), Some(stop)) => - if (isAfter(date, start) && isBefore(date, stop)) { - RangeMiddle; - } else { - NoRange; - } - | _ => NoRange + let startOpt = Js.Undefined.toOption(rangeValue.from); + let endOpt = Js.Undefined.toOption(rangeValue.to_); + switch (startOpt, endOpt) { + | (Some(start), Some(rangeEnd)) => + let isStart = isSameDay(date, start); + let isEnd = isSameDay(date, rangeEnd); + if (isStart && isEnd) { + RangeStartAndEnd; + } else if (isStart) { + RangeStart; + } else if (isEnd) { + RangeEnd; + } else if (isAfter(date, start) && isBefore(date, rangeEnd)) { + RangeMiddle; + } else { + NoRange; }; + | _ => NoRange }; | None => NoRange } @@ -515,8 +507,9 @@ let ordinalSuffix = (day: int): string => { }; }; -let formatAriaDayLabel = (date: Js.Date.t, ~isToday: bool): string => { +let formatAriaDayLabel = (date: Js.Date.t, ~isToday: bool, ~isSelected: bool): string => { let prefix = if (isToday) {"Today, "} else {""}; + let suffix = if (isSelected) {", selected"} else {""}; prefix ++ getWeekdayName(dayOfWeek(date)) ++ ", " @@ -525,7 +518,8 @@ let formatAriaDayLabel = (date: Js.Date.t, ~isToday: bool): string => { ++ string_of_int(dateDay(date)) ++ ordinalSuffix(dateDay(date)) ++ ", " - ++ string_of_int(dateYear(date)); + ++ string_of_int(dateYear(date)) + ++ suffix; }; let getDayClasses = @@ -556,6 +550,8 @@ let getDayClasses = modifiers := List.concat([modifiers^, [classNames.dayRangeMiddle]]) | RangeEnd => modifiers := List.concat([modifiers^, [classNames.dayRangeEnd]]) + | RangeStartAndEnd => + modifiers := List.concat([modifiers^, [classNames.dayRangeStart, classNames.dayRangeEnd]]) | NoRange => () }; @@ -866,13 +862,28 @@ let renderWeekRow = let cellClassName = getDayClasses(day, selected, today, showOutsideDays); let dayIsToday = isSameDay(day.date, today); + let dayIsSelected = isDateSelected(day.date, selected); let hasContent = showOutsideDays || !day.isOutside; - let tdProps = - ref([ - classNameProp(cellClassName), - roleProp("gridcell"), - stringProp("data-day", "dataDay", formatISODate(day.date)), + let tdProps = ref([classNameProp(cellClassName), roleProp("gridcell")]); + if (dayIsSelected) { + tdProps := + List.concat([ + tdProps^, + [stringProp("aria-selected", "ariaSelected", "true")], + ]); + }; + tdProps := + List.concat([ + tdProps^, + [stringProp("data-day", "dataDay", formatISODate(day.date))], ]); + if (dayIsSelected) { + tdProps := + List.concat([ + tdProps^, + [stringProp("data-selected", "dataSelected", "true")], + ]); + }; if (day.isOutside) { tdProps := List.concat([ @@ -914,7 +925,11 @@ let renderWeekRow = stringProp("type", "type", "button"), intProp("tabindex", "tabIndex", dayIsToday ? 0 : (-1)), ariaLabelProp( - formatAriaDayLabel(day.date, ~isToday=dayIsToday), + formatAriaDayLabel( + day.date, + ~isToday=dayIsToday, + ~isSelected=dayIsSelected, + ), ), ], [React.string(formatDayNumber(day.date))], @@ -936,6 +951,7 @@ let renderWeekRow = let renderMonth = ( monthIndex: int, + ~multiSelectable: bool, ~showOutsideDays: bool, ~showWeekNumber: bool, ~hideWeekdays: bool, @@ -1023,7 +1039,7 @@ let renderMonth = stringProp( "aria-multiselectable", "ariaMultiselectable", - "false", + multiSelectable ? "true" : "false", ), ariaLabelProp(formatMonthYear(monthStart)), classNameProp(classNames.monthGrid), @@ -1043,7 +1059,7 @@ let renderMonth = stringProp( "aria-multiselectable", "ariaMultiselectable", - "false", + multiSelectable ? "true" : "false", ), ariaLabelProp(formatMonthYear(monthStart)), classNameProp(classNames.monthGrid), @@ -1171,6 +1187,7 @@ let make = | Some(value) => modeToString(value) | None => modeToString(`Single) }; + let multiSelectable = currentMode == "multiple" || currentMode == "range"; let reverseYearsValue = switch (reverseYears) { | Some(value) => value @@ -1252,11 +1269,12 @@ let make = } else { None; }; - renderMonth( - monthIndex, - ~showOutsideDays=showOutside, - ~showWeekNumber=showWeekNum, - ~hideWeekdays=hideWeekdaysValue, + renderMonth( + monthIndex, + ~multiSelectable, + ~showOutsideDays=showOutside, + ~showWeekNumber=showWeekNum, + ~hideWeekdays=hideWeekdaysValue, ~fixedWeeks=fixedWeeksValue, ~captionLayout, ~reverseYears=reverseYearsValue, diff --git a/dune-project b/dune-project index 8a6b3ea..aedb73a 100644 --- a/dune-project +++ b/dune-project @@ -19,14 +19,15 @@ (name reason-react-day-picker) (synopsis "Melange bindings for react-day-picker") (description "Melange bindings for react-day-picker written in ReasonML") - (depends - ocaml - melange - reason - melange-webapi - reason-react-ppx - reason-react - server-reason-react) + (depends + ocaml + melange + reason + melange-webapi + reason-react-ppx + reason-react + server-reason-react + (alcotest :with-test)) (allow_empty) (tags ("melange" "reason" "bindings" "react-day-picker" "react"))) diff --git a/example/README.md b/example/README.md index 08e026c..64d0051 100644 --- a/example/README.md +++ b/example/README.md @@ -6,8 +6,8 @@ This folder follows the same split used by `server-reason-react` demos: - `example/js/`: Melange renderer using `ReactDOMServer.renderToString` - `example/native/`: native renderer using `ReactDOM.renderToString` -Both renderers use the same `ReactDayPicker` name and props, and should -produce matching HTML for the same fixture values in +Both renderers use the same `ReactDayPicker` name and props, and render both a +single picker and a range picker for the same fixture values in `example/shared/SharedFixture.re`, including `footer` via `React.string(...)`. ### Run the native renderer diff --git a/example/js/JsRenderer.re b/example/js/JsRenderer.re index 885b9e2..fe468ed 100644 --- a/example/js/JsRenderer.re +++ b/example/js/JsRenderer.re @@ -1,51 +1,4 @@ -open SharedFixture; - -let config = Scenario.current(); - -let demoDate = - Js.Date.make( - ~year=float_of_int(demoYear), - ~month=float_of_int(demoMonth), - ~date=float_of_int(demoDay), - ~hours=12.0, - ~minutes=0.0, - ~seconds=0.0, - (), - ); - -let footer = - switch (config.footerText) { - | Some(value) => Some(React.string(value)) - | None => None - }; - -let captionLayout = config.captionLayout; -let navLayout = config.navLayout; -let onSelect = `Single((_date: ReactDayPicker.singleDate) => ()); -let selected = `Single(Js.Undefined.return(demoDate)); - -let dayPicker = - ; - -let rendered = ReactDOMServer.renderToString(dayPicker); +let rendered = ReactDOMServer.renderToString(ExampleDayPickers.root); let () = print_endline("RENDER_START"); let () = print_endline(rendered); diff --git a/example/native/NativeRenderer.re b/example/native/NativeRenderer.re index b12112e..3f765d7 100644 --- a/example/native/NativeRenderer.re +++ b/example/native/NativeRenderer.re @@ -1,50 +1,4 @@ -open SharedFixture; - -let config = Scenario.current(); - -let demoDate = - Js.Date.make( - ~year=float_of_int(demoYear), - ~month=float_of_int(demoMonth), - ~date=float_of_int(demoDay), - ~hours=12.0, - ~minutes=0.0, - ~seconds=0.0, - (), - ); - -let footer = - switch (config.footerText) { - | Some(value) => Some(React.string(value)) - | None => None - }; - -let captionLayout = config.captionLayout; -let navLayout = config.navLayout; -let onSelect = `Single((_date: ReactDayPicker.singleDate) => ()); - -let dayPicker = - ; - -let rendered = ReactDOM.renderToString(dayPicker); +let rendered = ReactDOM.renderToString(ExampleDayPickers.root); let () = print_endline("RENDER_START"); let () = print_endline(rendered); diff --git a/example/shared/ExampleDayPickers.re b/example/shared/ExampleDayPickers.re new file mode 100644 index 0000000..408df61 --- /dev/null +++ b/example/shared/ExampleDayPickers.re @@ -0,0 +1,109 @@ +let config = Scenario.current(); + +let makeDate = (~year: int, ~month: int, ~day: int): Js.Date.t => + Js.Date.make( + ~year=float_of_int(year), + ~month=float_of_int(month), + ~date=float_of_int(day), + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), + ); + +let today = Js.Date.make(); +let openDate = today; +let closeDate = Js.Date.make( + ~year=Js.Date.getFullYear(today), + ~month=Js.Date.getMonth(today), + ~date=Js.Date.getDate(today) +. 4.0, + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), +); + +let footer = + switch (config.footerText) { + | Some(value) => Some(React.string(value)) + | None => None + }; + +let captionLayout = config.captionLayout; +let navLayout = config.navLayout; + +let renderPicker = ( + ~mode: string, + ~selected: ReactDayPicker.selected, + ~onSelect: ReactDayPicker.onSelect, +) => + ; + +let singleDayPicker = + renderPicker( + ~mode="single", + ~selected=`Single(ReactDayPicker.defined(today)), + ~onSelect=`Single((_date: ReactDayPicker.singleDate) => ()), + ); + +let rangeDayPicker = { + let openDateRef = ref(openDate); + let closeDateRef = ref(closeDate); + let setOpenDate = update => openDateRef := update(openDateRef^); + let setCloseDate = update => closeDateRef := update(closeDateRef^); + + renderPicker( + ~mode="range", + ~selected=`Range(ReactDayPicker.defined({ + ReactDayPicker.from: ReactDayPicker.defined(openDate), + ReactDayPicker.to_: ReactDayPicker.defined(closeDate), + })), + ~onSelect=`Range((dates: ReactDayPicker.rangeDate) => { + switch (Js.Undefined.toOption(dates)) { + | Some(dates) => + let openDate = + switch (Js.Undefined.toOption(dates.from)) { + | Some(date) => date + | None => today + }; + let closeDate = + switch (Js.Undefined.toOption(dates.to_)) { + | Some(date) => date + | None => openDate + }; + setOpenDate(_prev => openDate); + setCloseDate(_prev => closeDate); + | None => + setOpenDate(_prev => today); + setCloseDate(_prev => today); + } + }), + ); +}; + +let section = (~title: string, picker: React.element) => + React.array([|React.string(title), picker|]); + +let root = + React.array([| + section(~title="single", singleDayPicker), + section(~title="range", rangeDayPicker), + |]); diff --git a/example/shared/SharedFixture.re b/example/shared/SharedFixture.re index b276b8e..77ea72b 100644 --- a/example/shared/SharedFixture.re +++ b/example/shared/SharedFixture.re @@ -1,6 +1,4 @@ -let demoYear = 2026; -let demoMonth = 0; -let demoDay = 15; +/* Demo dates are now derived from today's date in ExampleDayPickers.re */ let numberOfMonths = 1; let showOutsideDays = true; diff --git a/reason-react-day-picker.opam b/reason-react-day-picker.opam index 3ad9311..51f8b53 100644 --- a/reason-react-day-picker.opam +++ b/reason-react-day-picker.opam @@ -38,5 +38,5 @@ dev-repo: "git+https://github.com/Software-Deployed/reason-react-day-picker.git" x-maintenance-intent: ["(latest)"] pin-depends: [ - [ "server-reason-react" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] + [ "server-reason-react.dev" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] ] diff --git a/reason-react-day-picker.opam.template b/reason-react-day-picker.opam.template index 6db865d..ea78c0d 100644 --- a/reason-react-day-picker.opam.template +++ b/reason-react-day-picker.opam.template @@ -1,3 +1,3 @@ pin-depends: [ - [ "server-reason-react" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] + [ "server-reason-react.dev" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] ] From 70d2652992efec400f4cd4db223e058d66dbcee5 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Sun, 22 Mar 2026 19:09:24 -0400 Subject: [PATCH 05/12] remove wrapper for Js.Undefined.return --- ReactDayPicker.re | 153 ++++++++++++++++++++++---------------------- example/native/dune | 4 +- 2 files changed, 79 insertions(+), 78 deletions(-) diff --git a/ReactDayPicker.re b/ReactDayPicker.re index 1984008..6e73a45 100644 --- a/ReactDayPicker.re +++ b/ReactDayPicker.re @@ -50,17 +50,12 @@ type onSelect = [ | `Range(rangeDate => unit) ]; -let defined = (value: 'a): Js.Undefined.t('a) => - switch%platform (Runtime.platform) { - | Server => Js.Undefined.fromOption(Some(value)) - | Client => Js.Undefined.return(value) - }; - [@platform js] module ClientImpl = { type jsDateRange = { from: singleDate, - [@mel.as "to"] to_: singleDate, + [@mel.as "to"] + to_: singleDate, }; type jsRangeDate = Js.Undefined.t(jsDateRange); @@ -79,7 +74,7 @@ module ClientImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - Js.Undefined.return(nextValue) + Js.Undefined.return(nextValue); | None => emptyRangeDate }; @@ -90,7 +85,7 @@ module ClientImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - defined(nextValue) + Js.Undefined.return(nextValue); | None => Js.Undefined.fromOption(None) }; @@ -130,7 +125,7 @@ module ClientImpl = { ~pagedNavigation: bool=?, ~showOutsideDays: bool=?, ~showWeekNumber: bool=?, - unit, + unit ) => { . @@ -177,26 +172,27 @@ module ClientImpl = { React.element = "DayPicker"; - let render = ( - ~mode, - ~selected, - ~onSelect, - ~captionLayout: option(captionLayout)=?, - ~reverseYears: option(bool)=?, - ~navLayout: option(navLayout)=?, - ~disableNavigation: option(bool)=?, - ~hideNavigation: option(bool)=?, - ~animate: option(bool)=?, - ~fixedWeeks: option(bool)=?, - ~footer: option(reactNode)=?, - ~hideWeekdays: option(bool)=?, - ~numberOfMonths: option(int)=?, - ~reverseMonths: option(bool)=?, - ~pagedNavigation: option(bool)=?, - ~showOutsideDays: option(bool)=?, - ~showWeekNumber: option(bool)=?, - (), - ) => { + let render = + ( + ~mode, + ~selected, + ~onSelect, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + (), + ) => { let captionLayout = switch (captionLayout) { | Some(value) => Some(captionLayoutToString(value)) @@ -241,7 +237,8 @@ module ServerImpl = { | _ => `Single }; - let captionLayoutFromShared = (value: captionLayout): ReactDayPickerNative.captionLayout => + let captionLayoutFromShared = + (value: captionLayout): ReactDayPickerNative.captionLayout => switch (value) { | `Label => `Label | `Dropdown => `Dropdown @@ -255,7 +252,8 @@ module ServerImpl = { | `After => `After }; - let emptyRangeDate: ReactDayPickerNative.rangeDate = Js.Undefined.fromOption(None); + let emptyRangeDate: ReactDayPickerNative.rangeDate = + Js.Undefined.fromOption(None); let toNativeRangeDate = (value: rangeDate): ReactDayPickerNative.rangeDate => switch (Js.Undefined.toOption(value)) { @@ -264,7 +262,7 @@ module ServerImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - defined(nextValue) + Js.Undefined.return(nextValue); | None => emptyRangeDate }; @@ -275,7 +273,7 @@ module ServerImpl = { from: rangeValue.from, to_: rangeValue.to_, }; - defined(nextValue) + Js.Undefined.return(nextValue); | None => Js.Undefined.fromOption(None) }; @@ -291,31 +289,33 @@ module ServerImpl = { | `Single(callback) => `Single(callback) | `Multiple(callback) => `Multiple(callback) | `Range(callback) => - `Range((dates: ReactDayPickerNative.rangeDate) => - callback(fromNativeRangeDate(dates)) + `Range( + (dates: ReactDayPickerNative.rangeDate) => + callback(fromNativeRangeDate(dates)), ) }; - let render = ( - ~mode, - ~selected, - ~onSelect, - ~captionLayout: option(captionLayout)=?, - ~reverseYears: option(bool)=?, - ~navLayout: option(navLayout)=?, - ~disableNavigation: option(bool)=?, - ~hideNavigation: option(bool)=?, - ~animate: option(bool)=?, - ~fixedWeeks: option(bool)=?, - ~footer: option(reactNode)=?, - ~hideWeekdays: option(bool)=?, - ~numberOfMonths: option(int)=?, - ~reverseMonths: option(bool)=?, - ~pagedNavigation: option(bool)=?, - ~showOutsideDays: option(bool)=?, - ~showWeekNumber: option(bool)=?, - (), - ) => { + let render = + ( + ~mode, + ~selected, + ~onSelect, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + (), + ) => { let captionLayout = switch (captionLayout) { | Some(value) => Some(captionLayoutFromShared(value)) @@ -350,26 +350,27 @@ module ServerImpl = { }; [@react.component] -let make = ( - ~mode: mode, - ~selected: selected, - ~onSelect: onSelect, - ~captionLayout: option(captionLayout)=?, - ~reverseYears: option(bool)=?, - ~navLayout: option(navLayout)=?, - ~disableNavigation: option(bool)=?, - ~hideNavigation: option(bool)=?, - ~animate: option(bool)=?, - ~fixedWeeks: option(bool)=?, - ~footer: option(reactNode)=?, - ~hideWeekdays: option(bool)=?, - ~numberOfMonths: option(int)=?, - ~reverseMonths: option(bool)=?, - ~pagedNavigation: option(bool)=?, - ~showOutsideDays: option(bool)=?, - ~showWeekNumber: option(bool)=?, - (), -) => +let make = + ( + ~mode: mode, + ~selected: selected, + ~onSelect: onSelect, + ~captionLayout: option(captionLayout)=?, + ~reverseYears: option(bool)=?, + ~navLayout: option(navLayout)=?, + ~disableNavigation: option(bool)=?, + ~hideNavigation: option(bool)=?, + ~animate: option(bool)=?, + ~fixedWeeks: option(bool)=?, + ~footer: option(reactNode)=?, + ~hideWeekdays: option(bool)=?, + ~numberOfMonths: option(int)=?, + ~reverseMonths: option(bool)=?, + ~pagedNavigation: option(bool)=?, + ~showOutsideDays: option(bool)=?, + ~showWeekNumber: option(bool)=?, + (), + ) => switch%platform (Runtime.platform) { | Server => ServerImpl.render( diff --git a/example/native/dune b/example/native/dune index 546c4bf..b267543 100644 --- a/example/native/dune +++ b/example/native/dune @@ -1,7 +1,7 @@ (copy_files# "../shared/*.re") -(executable - (name NativeRenderer) +(executables + (names NativeRenderer RangeNativeRenderer) (preprocess (pps server-reason-react.ppx)) (libraries From 99d6b45b1de9167f9096d85302c13ca98169146e Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Sun, 22 Mar 2026 19:10:34 -0400 Subject: [PATCH 06/12] add new tests --- example/check-range-parity.sh | 38 +++++ example/fuzz-test.sh | 12 ++ example/fuzz/FuzzGenerator.re | 181 +++++++++++++++++++++++ example/fuzz/FuzzRenderer.re | 99 +++++++++++++ example/fuzz/FuzzTestRunner.re | 198 ++++++++++++++++++++++++++ example/fuzz/dune | 9 ++ example/js/RangeJsRenderer.re | 5 + example/native/RangeNativeRenderer.re | 5 + example/shared/RangeRenderer.re | 38 +++++ example/shared/RangeScenario.re | 55 +++++++ 10 files changed, 640 insertions(+) create mode 100755 example/check-range-parity.sh create mode 100644 example/fuzz-test.sh create mode 100644 example/fuzz/FuzzGenerator.re create mode 100644 example/fuzz/FuzzRenderer.re create mode 100644 example/fuzz/FuzzTestRunner.re create mode 100644 example/fuzz/dune create mode 100644 example/js/RangeJsRenderer.re create mode 100644 example/native/RangeNativeRenderer.re create mode 100644 example/shared/RangeRenderer.re create mode 100644 example/shared/RangeScenario.re diff --git a/example/check-range-parity.sh b/example/check-range-parity.sh new file mode 100755 index 0000000..45ba8ca --- /dev/null +++ b/example/check-range-parity.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +if [[ ! -d node_modules/react || ! -d node_modules/react-dom || ! -d node_modules/react-day-picker ]]; then + printf 'Install npm runtime deps first: npm install react react-dom react-day-picker\n' >&2 + exit 1 +fi + +scenarios=( + range-same-day + range-multi-day + range-start-only + range-end-only +) + +if [[ $# -gt 0 ]]; then + scenarios=("$@") +fi + +tmpdir="$(mktemp -d)" +trap 'rm -rf "$tmpdir"' EXIT + +dune build -j1 @example/js/melange example/native/RangeNativeRenderer.exe + +for scenario in "${scenarios[@]}"; do + node "_build/default/example/js/render/example/js/RangeJsRenderer.re.js" "$scenario" > "$tmpdir/js.txt" + dune exec ./example/native/RangeNativeRenderer.exe -- "$scenario" > "$tmpdir/native.txt" + if ! diff -u "$tmpdir/native.txt" "$tmpdir/js.txt"; then + printf 'Range parity failed for scenario: %s\n' "$scenario" >&2 + exit 1 + fi + printf 'ok %s\n' "$scenario" +done + +printf 'All range parity scenarios matched.\n' diff --git a/example/fuzz-test.sh b/example/fuzz-test.sh new file mode 100644 index 0000000..8ad0624 --- /dev/null +++ b/example/fuzz-test.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash +set -euo pipefail + +repo_root="$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$repo_root" + +count=${1:-30} + +echo "Running $count fuzz tests..." + +dune build example/fuzz/FuzzTestRunner.exe +dune exec example/fuzz/FuzzTestRunner.exe diff --git a/example/fuzz/FuzzGenerator.re b/example/fuzz/FuzzGenerator.re new file mode 100644 index 0000000..18e3b87 --- /dev/null +++ b/example/fuzz/FuzzGenerator.re @@ -0,0 +1,181 @@ +type fuzzConfig = { + name: string, + mode: string, + captionLayout: option(Scenario.captionLayout), + reverseYears: bool, + navLayout: option(Scenario.navLayout), + disableNavigation: bool, + hideNavigation: bool, + animate: bool, + fixedWeeks: bool, + footerText: option(string), + hideWeekdays: bool, + numberOfMonths: int, + reverseMonths: bool, + pagedNavigation: bool, + showOutsideDays: bool, + showWeekNumber: bool, + singleDate: option(Js.Date.t), + multipleDates: option(array(Js.Date.t)), + rangeFrom: option(Js.Date.t), + rangeTo: option(Js.Date.t), +}; + +let randomBool = () => Random.int(2) == 0; + +let randomInt = (~min, ~max) => min + Random.int(max - min + 1); + +let randomOption = (value: 'a) => randomBool() ? Some(value) : None; + +let randomCaptionLayout = () => + switch (Random.int(4)) { + | 0 => Some(`Label) + | 1 => Some(`Dropdown) + | 2 => Some(`DropdownMonths) + | 3 => Some(`DropdownYears) + | _ => None + }; + +let randomNavLayout = () => + switch (Random.int(3)) { + | 0 => Some(`Around) + | 1 => Some(`After) + | _ => None + }; + +let randomDate = (~baseYear, ~baseMonth) => { + let day = randomInt(~min=1, ~max=28); + Js.Date.make( + ~year=float_of_int(baseYear), + ~month=float_of_int(baseMonth), + ~date=float_of_int(day), + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), + ); +}; + +let generateSingleConfig = (~seed, ~baseYear, ~baseMonth): fuzzConfig => { + Random.init(seed); + let mode = "single"; + let singleDate = + if (randomBool()) { + Some(randomDate(~baseYear, ~baseMonth)); + } else { + None; + }; + + { + name: "fuzz-single-" ++ string_of_int(seed), + mode, + captionLayout: randomCaptionLayout(), + reverseYears: randomBool(), + navLayout: randomNavLayout(), + disableNavigation: randomBool(), + hideNavigation: randomBool(), + animate: randomBool(), + fixedWeeks: randomBool(), + footerText: if (randomBool()) {Some("Fuzz test")} else {None}, + hideWeekdays: randomBool(), + numberOfMonths: randomInt(~min=1, ~max=3), + reverseMonths: randomBool(), + pagedNavigation: randomBool(), + showOutsideDays: randomBool(), + showWeekNumber: randomBool(), + singleDate, + multipleDates: None, + rangeFrom: None, + rangeTo: None, + }; +}; + +let generateMultipleConfig = (~seed, ~baseYear, ~baseMonth): fuzzConfig => { + Random.init(seed); + let mode = "multiple"; + let count = randomInt(~min=1, ~max=5); + let multipleDates = + if (randomBool()) { + Some(Array.init(count, _ => randomDate(~baseYear, ~baseMonth))); + } else { + None; + }; + + { + name: "fuzz-multiple-" ++ string_of_int(seed), + mode, + captionLayout: randomCaptionLayout(), + reverseYears: randomBool(), + navLayout: randomNavLayout(), + disableNavigation: randomBool(), + hideNavigation: randomBool(), + animate: randomBool(), + fixedWeeks: randomBool(), + footerText: if (randomBool()) {Some("Fuzz test")} else {None}, + hideWeekdays: randomBool(), + numberOfMonths: randomInt(~min=1, ~max=3), + reverseMonths: randomBool(), + pagedNavigation: randomBool(), + showOutsideDays: randomBool(), + showWeekNumber: randomBool(), + singleDate: None, + multipleDates, + rangeFrom: None, + rangeTo: None, + }; +}; + +let generateRangeConfig = (~seed, ~baseYear, ~baseMonth): fuzzConfig => { + Random.init(seed); + let mode = "range"; + let hasRange = randomBool(); + let rangeFrom = hasRange ? Some(randomDate(~baseYear, ~baseMonth)) : None; + let rangeTo = + switch (rangeFrom) { + | Some(from) => + let fromDay = Js.Date.getDate(from); + let toDay = fromDay +. float_of_int(randomInt(~min=0, ~max=14)); + Some(Js.Date.make( + ~year=Js.Date.getFullYear(from), + ~month=Js.Date.getMonth(from), + ~date=toDay, + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), + )); + | None => None + }; + + { + name: "fuzz-range-" ++ string_of_int(seed), + mode, + captionLayout: randomCaptionLayout(), + reverseYears: randomBool(), + navLayout: randomNavLayout(), + disableNavigation: randomBool(), + hideNavigation: randomBool(), + animate: randomBool(), + fixedWeeks: randomBool(), + footerText: if (randomBool()) {Some("Fuzz test")} else {None}, + hideWeekdays: randomBool(), + numberOfMonths: randomInt(~min=1, ~max=3), + reverseMonths: randomBool(), + pagedNavigation: randomBool(), + showOutsideDays: randomBool(), + showWeekNumber: randomBool(), + singleDate: None, + multipleDates: None, + rangeFrom, + rangeTo, + }; +}; + +let generateConfig = (~seed, ~baseYear, ~baseMonth): fuzzConfig => { + let modeType = Random.int(3); + switch (modeType) { + | 0 => generateSingleConfig(~seed, ~baseYear, ~baseMonth) + | 1 => generateMultipleConfig(~seed, ~baseYear, ~baseMonth) + | _ => generateRangeConfig(~seed, ~baseYear, ~baseMonth) + }; +}; diff --git a/example/fuzz/FuzzRenderer.re b/example/fuzz/FuzzRenderer.re new file mode 100644 index 0000000..ed2b796 --- /dev/null +++ b/example/fuzz/FuzzRenderer.re @@ -0,0 +1,99 @@ +type fuzzConfig = FuzzGenerator.fuzzConfig; + +let captionLayoutFromString = (value: string): option(Scenario.captionLayout) => + switch (value) { + | "Label" => Some(`Label) + | "Dropdown" => Some(`Dropdown) + | "DropdownMonths" => Some(`DropdownMonths) + | "DropdownYears" => Some(`DropdownYears) + | _ => None + }; + +let navLayoutFromString = (value: string): option(Scenario.navLayout) => + switch (value) { + | "Around" => Some(`Around) + | "After" => Some(`After) + | _ => None + }; + +let renderFuzzConfig = (config: fuzzConfig) => { + let captionLayout = switch (config.captionLayout) { + | Some(`Label) => Some(`Label) + | Some(`Dropdown) => Some(`Dropdown) + | Some(`DropdownMonths) => Some(`DropdownMonths) + | Some(`DropdownYears) => Some(`DropdownYears) + | None => None + }; + let navLayout = switch (config.navLayout) { + | Some(`Around) => Some(`Around) + | Some(`After) => Some(`After) + | None => None + }; + let footer = + switch (config.footerText) { + | Some(t) => Some(React.string(t)) + | None => None + }; + + let selected = + switch (config.mode) { + | "single" => + let date = + switch (config.singleDate) { + | Some(d) => ReactDayPicker.defined(d) + | None => Js.Undefined.fromOption(None) + }; + `Single(date); + | "multiple" => + let dates = + switch (config.multipleDates) { + | Some(d) => ReactDayPicker.defined(d) + | None => Js.Undefined.fromOption(None) + }; + `Multiple(dates); + | "range" => + let range = + switch (config.rangeFrom) { + | Some(from) => + switch (config.rangeTo) { + | Some(to_) => + ReactDayPicker.defined({ + ReactDayPicker.from: ReactDayPicker.defined(from), + ReactDayPicker.to_: ReactDayPicker.defined(to_), + }); + | None => Js.Undefined.fromOption(None) + }; + | None => Js.Undefined.fromOption(None) + }; + `Range(range); + | _ => `Single(Js.Undefined.fromOption(None)) + }; + + let onSelect = + switch (config.mode) { + | "single" => `Single((_date: ReactDayPicker.singleDate) => ()) + | "multiple" => `Multiple((_dates: ReactDayPicker.multipleDate) => ()) + | "range" => `Range((_dates: ReactDayPicker.rangeDate) => ()) + | _ => `Single((_date: ReactDayPicker.singleDate) => ()) + }; + + ; +}; diff --git a/example/fuzz/FuzzTestRunner.re b/example/fuzz/FuzzTestRunner.re new file mode 100644 index 0000000..b7a97d5 --- /dev/null +++ b/example/fuzz/FuzzTestRunner.re @@ -0,0 +1,198 @@ +let normalizeHtml = (html: string): string => { + html + |> String.trim + |> String.split_on_char('\n') + |> List.map(String.trim) + |> String.concat("") +}; + +let renderNative = (config: FuzzGenerator.fuzzConfig): string => { + let element = FuzzRenderer.renderFuzzConfig(config); + ReactDOM.renderToString(element); +}; + +let runNativeTest = (config: FuzzGenerator.fuzzConfig) => { + let nativeOutput = renderNative(config); + normalizeHtml(nativeOutput); +}; + +let testFuzzConfig = (name, config) => + Alcotest.test_case(name, `Quick, () => { + let _output = runNativeTest(config); + Alcotest.check(Alcotest.bool)("Native renders successfully", true, true); + }); + +let makeDate = (~year: int, ~month: int, ~day: int): Js.Date.t => + Js.Date.make( + ~year=float_of_int(year), + ~month=float_of_int(month), + ~date=float_of_int(day), + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), + ); + +let () = { + let baseYear = 2026; + let baseMonth = 2; + let today = makeDate(~year=baseYear, ~month=baseMonth, ~day=21); + let tomorrow = makeDate(~year=baseYear, ~month=baseMonth, ~day=22); + let nextWeek = makeDate(~year=baseYear, ~month=baseMonth, ~day=28); + + let rangeTests = [ + testFuzzConfig( + "range-same-day", + { + FuzzGenerator.name: "range-same-day", + mode: "range", + captionLayout: None, + reverseYears: false, + navLayout: None, + disableNavigation: false, + hideNavigation: false, + animate: false, + fixedWeeks: false, + footerText: None, + hideWeekdays: false, + numberOfMonths: 1, + reverseMonths: false, + pagedNavigation: false, + showOutsideDays: true, + showWeekNumber: false, + singleDate: None, + multipleDates: None, + rangeFrom: Some(today), + rangeTo: Some(today), + }, + ), + testFuzzConfig( + "range-multi-day", + { + FuzzGenerator.name: "range-multi-day", + mode: "range", + captionLayout: None, + reverseYears: false, + navLayout: None, + disableNavigation: false, + hideNavigation: false, + animate: false, + fixedWeeks: false, + footerText: None, + hideWeekdays: false, + numberOfMonths: 1, + reverseMonths: false, + pagedNavigation: false, + showOutsideDays: true, + showWeekNumber: false, + singleDate: None, + multipleDates: None, + rangeFrom: Some(today), + rangeTo: Some(tomorrow), + }, + ), + testFuzzConfig( + "range-start-only", + { + FuzzGenerator.name: "range-start-only", + mode: "range", + captionLayout: None, + reverseYears: false, + navLayout: None, + disableNavigation: false, + hideNavigation: false, + animate: false, + fixedWeeks: false, + footerText: None, + hideWeekdays: false, + numberOfMonths: 1, + reverseMonths: false, + pagedNavigation: false, + showOutsideDays: true, + showWeekNumber: false, + singleDate: None, + multipleDates: None, + rangeFrom: Some(today), + rangeTo: None, + }, + ), + testFuzzConfig( + "range-end-only", + { + FuzzGenerator.name: "range-end-only", + mode: "range", + captionLayout: None, + reverseYears: false, + navLayout: None, + disableNavigation: false, + hideNavigation: false, + animate: false, + fixedWeeks: false, + footerText: None, + hideWeekdays: false, + numberOfMonths: 1, + reverseMonths: false, + pagedNavigation: false, + showOutsideDays: true, + showWeekNumber: false, + singleDate: None, + multipleDates: None, + rangeFrom: None, + rangeTo: Some(today), + }, + ), + testFuzzConfig( + "range-week-long", + { + FuzzGenerator.name: "range-week-long", + mode: "range", + captionLayout: None, + reverseYears: false, + navLayout: None, + disableNavigation: false, + hideNavigation: false, + animate: false, + fixedWeeks: false, + footerText: None, + hideWeekdays: false, + numberOfMonths: 1, + reverseMonths: false, + pagedNavigation: false, + showOutsideDays: true, + showWeekNumber: false, + singleDate: None, + multipleDates: None, + rangeFrom: Some(today), + rangeTo: Some(nextWeek), + }, + ), + ]; + + let singleTests = Array.init(5, i => + testFuzzConfig( + "single-" ++ string_of_int(i), + FuzzGenerator.generateSingleConfig(~seed=i, ~baseYear, ~baseMonth), + ) + ) |> Array.to_list; + + let multipleTests = Array.init(5, i => + testFuzzConfig( + "multiple-" ++ string_of_int(i), + FuzzGenerator.generateMultipleConfig(~seed=100+i, ~baseYear, ~baseMonth), + ) + ) |> Array.to_list; + + let randomRangeTests = Array.init(5, i => + testFuzzConfig( + "random-range-" ++ string_of_int(i), + FuzzGenerator.generateRangeConfig(~seed=200+i, ~baseYear, ~baseMonth), + ) + ) |> Array.to_list; + + Alcotest.run("Fuzz Native Render Tests", [ + ("range-specific", rangeTests), + ("single-mode", singleTests), + ("multiple-mode", multipleTests), + ("range-random", randomRangeTests), + ]); +}; diff --git a/example/fuzz/dune b/example/fuzz/dune new file mode 100644 index 0000000..731b435 --- /dev/null +++ b/example/fuzz/dune @@ -0,0 +1,9 @@ +(copy_files# "../shared/*.re") + +(executable + (name FuzzTestRunner) + (modes native) + (libraries alcotest server-reason-react.react server-reason-react.reactDom server-reason-react.js reason-react-day-picker.native) + (modules FuzzGenerator FuzzRenderer FuzzTestRunner Scenario) + (preprocess + (pps server-reason-react.ppx))) diff --git a/example/js/RangeJsRenderer.re b/example/js/RangeJsRenderer.re new file mode 100644 index 0000000..a6e2467 --- /dev/null +++ b/example/js/RangeJsRenderer.re @@ -0,0 +1,5 @@ +let rendered = ReactDOMServer.renderToString(RangeRenderer.root); + +let () = print_endline("RENDER_START"); +let () = print_endline(rendered); +let () = print_endline("RENDER_END"); diff --git a/example/native/RangeNativeRenderer.re b/example/native/RangeNativeRenderer.re new file mode 100644 index 0000000..bd264a6 --- /dev/null +++ b/example/native/RangeNativeRenderer.re @@ -0,0 +1,5 @@ +let rendered = ReactDOM.renderToString(RangeRenderer.root); + +let () = print_endline("RENDER_START"); +let () = print_endline(rendered); +let () = print_endline("RENDER_END"); diff --git a/example/shared/RangeRenderer.re b/example/shared/RangeRenderer.re new file mode 100644 index 0000000..400b1d9 --- /dev/null +++ b/example/shared/RangeRenderer.re @@ -0,0 +1,38 @@ +let config = RangeScenario.current(); + +let selected = + switch (config.rangeFrom) { + | Some(from) => + switch (config.rangeTo) { + | Some(to_) => + `Range(ReactDayPicker.defined({ + ReactDayPicker.from: ReactDayPicker.defined(from), + ReactDayPicker.to_: ReactDayPicker.defined(to_), + })); + | None => + `Range(ReactDayPicker.defined({ + ReactDayPicker.from: ReactDayPicker.defined(from), + ReactDayPicker.to_: Js.Undefined.fromOption(None), + })); + }; + | None => + switch (config.rangeTo) { + | Some(to_) => + `Range(ReactDayPicker.defined({ + ReactDayPicker.from: Js.Undefined.fromOption(None), + ReactDayPicker.to_: ReactDayPicker.defined(to_), + })); + | None => + `Range(Js.Undefined.fromOption(None)); + }; + }; + +let onSelect = `Range((_dates: ReactDayPicker.rangeDate) => ()); + +let root = + ; diff --git a/example/shared/RangeScenario.re b/example/shared/RangeScenario.re new file mode 100644 index 0000000..62951e7 --- /dev/null +++ b/example/shared/RangeScenario.re @@ -0,0 +1,55 @@ +type testConfig = { + name: string, + mode: string, + rangeFrom: option(Js.Date.t), + rangeTo: option(Js.Date.t), +}; + +let makeDate = (~year: int, ~month: int, ~day: int): Js.Date.t => + Js.Date.make( + ~year=float_of_int(year), + ~month=float_of_int(month), + ~date=float_of_int(day), + ~hours=12.0, + ~minutes=0.0, + ~seconds=0.0, + (), + ); + +let today = makeDate(~year=2026, ~month=2, ~day=21); +let tomorrow = makeDate(~year=2026, ~month=2, ~day=22); +let nextWeek = makeDate(~year=2026, ~month=2, ~day=28); + +let all = [| + "range-same-day", + "range-multi-day", + "range-start-only", + "range-end-only", +|]; + +let byName = (name: string): testConfig => { + let (rangeFrom, rangeTo) = + switch (name) { + | "range-same-day" => (Some(today), Some(today)) + | "range-multi-day" => (Some(today), Some(tomorrow)) + | "range-start-only" => (Some(today), None) + | "range-end-only" => (None, Some(today)) + | _ => (Some(today), Some(tomorrow)) + }; + {name, mode: "range", rangeFrom, rangeTo}; +}; + +let current = (): testConfig => { + let name = + if (Array.length(Sys.argv) > 1) { + let candidate = Sys.argv[Array.length(Sys.argv) - 1]; + if (Array.exists((value) => value == candidate, all)) { + candidate; + } else { + "range-same-day"; + }; + } else { + "range-same-day"; + }; + byName(name); +}; From 8d2155c689bbcd38a25dbb392afee1156121de34 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Mon, 23 Mar 2026 14:34:55 -0400 Subject: [PATCH 07/12] Fix hydration mismatch: use selected day for tabIndex instead of today --- ReactDayPickerNative.re | 16 ++++++++-------- example/fuzz/FuzzRenderer.re | 10 +++++----- example/shared/ExampleDayPickers.re | 8 ++++---- example/shared/RangeRenderer.re | 14 +++++++------- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/ReactDayPickerNative.re b/ReactDayPickerNative.re index 2ab6830..0a21a15 100644 --- a/ReactDayPickerNative.re +++ b/ReactDayPickerNative.re @@ -916,14 +916,14 @@ let renderWeekRow = ]); }; - let content = - if (hasContent) { - element( - "button", - ~props=[ - classNameProp(classNames.dayButton), - stringProp("type", "type", "button"), - intProp("tabindex", "tabIndex", dayIsToday ? 0 : (-1)), + let content = + if (hasContent) { + element( + "button", + ~props=[ + classNameProp(classNames.dayButton), + stringProp("type", "type", "button"), + intProp("tabindex", "tabIndex", dayIsSelected ? 0 : (-1)), ariaLabelProp( formatAriaDayLabel( day.date, diff --git a/example/fuzz/FuzzRenderer.re b/example/fuzz/FuzzRenderer.re index ed2b796..2bed194 100644 --- a/example/fuzz/FuzzRenderer.re +++ b/example/fuzz/FuzzRenderer.re @@ -40,14 +40,14 @@ let renderFuzzConfig = (config: fuzzConfig) => { | "single" => let date = switch (config.singleDate) { - | Some(d) => ReactDayPicker.defined(d) + | Some(d) => Js.Undefined.return(d) | None => Js.Undefined.fromOption(None) }; `Single(date); | "multiple" => let dates = switch (config.multipleDates) { - | Some(d) => ReactDayPicker.defined(d) + | Some(d) => Js.Undefined.return(d) | None => Js.Undefined.fromOption(None) }; `Multiple(dates); @@ -57,9 +57,9 @@ let renderFuzzConfig = (config: fuzzConfig) => { | Some(from) => switch (config.rangeTo) { | Some(to_) => - ReactDayPicker.defined({ - ReactDayPicker.from: ReactDayPicker.defined(from), - ReactDayPicker.to_: ReactDayPicker.defined(to_), + Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(from), + ReactDayPicker.to_: Js.Undefined.return(to_), }); | None => Js.Undefined.fromOption(None) }; diff --git a/example/shared/ExampleDayPickers.re b/example/shared/ExampleDayPickers.re index 408df61..191292f 100644 --- a/example/shared/ExampleDayPickers.re +++ b/example/shared/ExampleDayPickers.re @@ -60,7 +60,7 @@ let renderPicker = ( let singleDayPicker = renderPicker( ~mode="single", - ~selected=`Single(ReactDayPicker.defined(today)), + ~selected=`Single(Js.Undefined.return(today)), ~onSelect=`Single((_date: ReactDayPicker.singleDate) => ()), ); @@ -72,9 +72,9 @@ let rangeDayPicker = { renderPicker( ~mode="range", - ~selected=`Range(ReactDayPicker.defined({ - ReactDayPicker.from: ReactDayPicker.defined(openDate), - ReactDayPicker.to_: ReactDayPicker.defined(closeDate), + ~selected=`Range(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(openDate), + ReactDayPicker.to_: Js.Undefined.return(closeDate), })), ~onSelect=`Range((dates: ReactDayPicker.rangeDate) => { switch (Js.Undefined.toOption(dates)) { diff --git a/example/shared/RangeRenderer.re b/example/shared/RangeRenderer.re index 400b1d9..e70560b 100644 --- a/example/shared/RangeRenderer.re +++ b/example/shared/RangeRenderer.re @@ -5,22 +5,22 @@ let selected = | Some(from) => switch (config.rangeTo) { | Some(to_) => - `Range(ReactDayPicker.defined({ - ReactDayPicker.from: ReactDayPicker.defined(from), - ReactDayPicker.to_: ReactDayPicker.defined(to_), + `Range(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(from), + ReactDayPicker.to_: Js.Undefined.return(to_), })); | None => - `Range(ReactDayPicker.defined({ - ReactDayPicker.from: ReactDayPicker.defined(from), + `Range(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(from), ReactDayPicker.to_: Js.Undefined.fromOption(None), })); }; | None => switch (config.rangeTo) { | Some(to_) => - `Range(ReactDayPicker.defined({ + `Range(Js.Undefined.return({ ReactDayPicker.from: Js.Undefined.fromOption(None), - ReactDayPicker.to_: ReactDayPicker.defined(to_), + ReactDayPicker.to_: Js.Undefined.return(to_), })); | None => `Range(Js.Undefined.fromOption(None)); From 7d3b99cb6419184cecb9359e751f29daa620dc1a Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Mon, 23 Mar 2026 14:39:39 -0400 Subject: [PATCH 08/12] add alcotest --- reason-react-day-picker.opam | 1 + 1 file changed, 1 insertion(+) diff --git a/reason-react-day-picker.opam b/reason-react-day-picker.opam index 51f8b53..46f6f61 100644 --- a/reason-react-day-picker.opam +++ b/reason-react-day-picker.opam @@ -18,6 +18,7 @@ depends: [ "reason-react-ppx" "reason-react" "server-reason-react" + "alcotest" {with-test} "odoc" {with-doc} ] build: [ From 6052ad85cfc0b2dc710f972c0558f59767cb83f8 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Mon, 23 Mar 2026 15:36:04 -0400 Subject: [PATCH 09/12] Fix isBefore/isAfter to compare dates without time --- ReactDayPickerNative.re | 34 ++++++++++++++++++++++++++++++---- 1 file changed, 30 insertions(+), 4 deletions(-) diff --git a/ReactDayPickerNative.re b/ReactDayPickerNative.re index 0a21a15..306b2bf 100644 --- a/ReactDayPickerNative.re +++ b/ReactDayPickerNative.re @@ -188,10 +188,36 @@ let setMonth = (date: Js.Date.t, month: int): Js.Date.t => { let startOfMonth = (date: Js.Date.t): Js.Date.t => newDate(~year=dateYear(date), ~month=dateMonth(date), ~day=1); -let isBefore = (a: Js.Date.t, b: Js.Date.t): bool => - Js.Date.getTime(a) < Js.Date.getTime(b); -let isAfter = (a: Js.Date.t, b: Js.Date.t): bool => - Js.Date.getTime(a) > Js.Date.getTime(b); +let isBefore = (a: Js.Date.t, b: Js.Date.t): bool => { + let yearA = dateYear(a); + let yearB = dateYear(b); + if (yearA != yearB) { + yearA < yearB; + } else { + let monthA = dateMonth(a); + let monthB = dateMonth(b); + if (monthA != monthB) { + monthA < monthB; + } else { + dateDay(a) < dateDay(b); + }; + }; +}; +let isAfter = (a: Js.Date.t, b: Js.Date.t): bool => { + let yearA = dateYear(a); + let yearB = dateYear(b); + if (yearA != yearB) { + yearA > yearB; + } else { + let monthA = dateMonth(a); + let monthB = dateMonth(b); + if (monthA != monthB) { + monthA > monthB; + } else { + dateDay(a) > dateDay(b); + }; + }; +}; let isSameDay = (a: Js.Date.t, b: Js.Date.t): bool => { dateYear(a) == dateYear(b) From c42e5df7f0cdb82274fbb53a91c926242c76b553 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Mon, 23 Mar 2026 16:43:31 -0400 Subject: [PATCH 10/12] fix range focus target to match react-day-picker --- ReactDayPickerNative.re | 68 +++++++++++++++++++++++++++++++++++------ 1 file changed, 59 insertions(+), 9 deletions(-) diff --git a/ReactDayPickerNative.re b/ReactDayPickerNative.re index 306b2bf..3435921 100644 --- a/ReactDayPickerNative.re +++ b/ReactDayPickerNative.re @@ -468,6 +468,48 @@ let isDateSelected = (date: Js.Date.t, selected: option(selected)): bool => { }; }; +let isFocusableDay = (day: calendarDay): bool => !day.isOutside; + +let getInitialFocusTarget = ( + weeks: array(calendarWeek), + selected: option(selected), + today: Js.Date.t, +): option(Js.Date.t) => { + let firstFocusable = ref(None); + let selectedFocusable = ref(None); + let todayFocusable = ref(None); + let setIfEmpty = (targetRef, value) => + switch (targetRef^) { + | None => targetRef := Some(value) + | Some(_) => () + }; + + weeks + |> Array.iter((week: calendarWeek) => + week.days + |> Array.iter((day: calendarDay) => { + if (isFocusableDay(day)) { + setIfEmpty(firstFocusable, day.date); + if (isSameDay(day.date, today)) { + setIfEmpty(todayFocusable, day.date); + }; + if (isDateSelected(day.date, selected)) { + setIfEmpty(selectedFocusable, day.date); + }; + }; + }) + ); + + switch (selectedFocusable^) { + | Some(_) as target => target + | None => + switch (todayFocusable^) { + | Some(_) as target => target + | None => firstFocusable^ + } + }; +}; + type rangePosition = | NoRange | RangeStart @@ -862,6 +904,7 @@ let renderWeekRow = ( week: calendarWeek, ~showWeekNum: bool, + ~focusTarget: option(Js.Date.t), selected: option(selected), today: Js.Date.t, showOutsideDays: bool, @@ -889,6 +932,11 @@ let renderWeekRow = getDayClasses(day, selected, today, showOutsideDays); let dayIsToday = isSameDay(day.date, today); let dayIsSelected = isDateSelected(day.date, selected); + let dayIsFocusTarget = + switch (focusTarget) { + | Some(target) => isSameDay(day.date, target) + | None => false + }; let hasContent = showOutsideDays || !day.isOutside; let tdProps = ref([classNameProp(cellClassName), roleProp("gridcell")]); if (dayIsSelected) { @@ -949,10 +997,10 @@ let renderWeekRow = ~props=[ classNameProp(classNames.dayButton), stringProp("type", "type", "button"), - intProp("tabindex", "tabIndex", dayIsSelected ? 0 : (-1)), - ariaLabelProp( - formatAriaDayLabel( - day.date, + intProp("tabindex", "tabIndex", dayIsFocusTarget ? 0 : (-1)), + ariaLabelProp( + formatAriaDayLabel( + day.date, ~isToday=dayIsToday, ~isSelected=dayIsSelected, ), @@ -994,17 +1042,19 @@ let renderMonth = let monthStart = startOfMonth(today) |> setMonth(_, dateMonth(today) + monthIndex); let weeks = buildMonthWeeks(monthStart, ~weekStartsOn=0, ~fixedWeeks, ()); + let focusTarget = getInitialFocusTarget(weeks, selected, today); let weekdayHeader = hideWeekdays ? React.null : renderWeekdayHeader(showWeekNumber, ~animate); let weekRows = weeks |> Array.map((week: calendarWeek) => renderWeekRow( - week, - ~showWeekNum=showWeekNumber, - selected, - today, - showOutsideDays, + week, + ~showWeekNum=showWeekNumber, + ~focusTarget, + selected, + today, + showOutsideDays, ) ); From 921bb5686f4bf3c416293373e41a125a2c3715bc Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Mon, 23 Mar 2026 19:42:52 -0400 Subject: [PATCH 11/12] chore:reformat --- dune-project | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/dune-project b/dune-project index aedb73a..a314492 100644 --- a/dune-project +++ b/dune-project @@ -15,26 +15,26 @@ (using melange 0.1) +(pin + (url "git+https://github.com/ml-in-barcelona/server-reason-react.git") + (package + (name server-reason-react))) + (package (name reason-react-day-picker) (synopsis "Melange bindings for react-day-picker") (description "Melange bindings for react-day-picker written in ReasonML") - (depends - ocaml - melange - reason - melange-webapi - reason-react-ppx - reason-react - server-reason-react - (alcotest :with-test)) + (depends + ocaml + melange + reason + melange-webapi + reason-react-ppx + reason-react + server-reason-react + (alcotest :with-test)) (allow_empty) (tags ("melange" "reason" "bindings" "react-day-picker" "react"))) -(pin - (url "git+https://github.com/ml-in-barcelona/server-reason-react.git") - (package - (name server-reason-react))) - ; See the complete stanza docs at https://dune.readthedocs.io/en/stable/reference/dune-project/index.html From 85d5120858d5afc2464d2255357ecdece94a6ab2 Mon Sep 17 00:00:00 2001 From: Brian Kaplan Date: Wed, 25 Mar 2026 17:06:54 -0400 Subject: [PATCH 12/12] docs: fix outdated ReactDayPicker.defined references --- README.md | 26 +++----------------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index aa903b4..2e78375 100644 --- a/README.md +++ b/README.md @@ -40,9 +40,9 @@ let calendar = ())} - selected={`Single(ReactDayPicker.defined(today))} - numberOfMonths=1 - showOutsideDays=true - />; - -/* Render to HTML string */ -let html = ReactDOM.renderToString(calendar); -``` - ### Example parity check This repo includes a small universal example in `example/` that mirrors the