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..2e78375 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,210 @@ 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. Pin and install `server-reason-react` from git: +```bash +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: +```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 closeDate = - switch (dates.to_->Js.Undefined.toOption) { - | Some(date) => date - | None => openDate - }; - updateOpenDate(openDate); - updateCloseDate(closeDate); - | None => { - updateOpenDate(today); - updateCloseDate(today); +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. + +### Example parity check + +This repo includes a small universal example in `example/` that mirrors the +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 +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. + +#### Props + +The shared `ReactDayPicker` component supports the following props: + +| Prop | Type | Description | +|------|------|-------------| +| `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) | +| `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 = string; + +type captionLayout = [ + | `Label + | `Dropdown + | `DropdownMonths + | `DropdownYears +]; + +type navLayout = [ + | `Around + | `After +]; + +type singleDate = Js.Undefined.t(Js.Date.t); +type multipleDate = Js.Undefined.t(array(Js.Date.t)); + +type selected = [ + | `Single(singleDate) + | `Multiple(multipleDate) + | `Range(rangeDate) +]; + +type onSelect = [ + | `Single(singleDate => unit) + | `Multiple(multipleDate => unit) + | `Range(rangeDate => unit) +]; + +type dateRange = { + from: singleDate, + to_: singleDate, +}; + +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..6e73a45 100644 --- a/ReactDayPicker.re +++ b/ReactDayPicker.re @@ -1,115 +1,417 @@ -type captionLayout = - | Label - | Dropdown - | DropdownMonths - | DropdownYears; - -let captionLayoutToString = (v: captionLayout): string => - switch (v) { - | Label => "label" - | Dropdown => "dropdown" - | DropdownMonths => "dropdown-months" - | DropdownYears => "dropdown-years" +type mode = string; + +type captionLayout = [ + | `Label + | `Dropdown + | `DropdownMonths + | `DropdownYears +]; + +let captionLayoutToString = (value: captionLayout): string => + switch (value) { + | `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" +let navLayoutToString = (value: navLayout): string => + switch (value) { + | `Around => "around" + | `After => "after" }; -/* `footer` can be a string or a React node. */ -type footer = - | FooterString(string) - | FooterNode(React.element); +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, - [@mel.as "to"] to_: singleDate, }; + type rangeDate = Js.Undefined.t(dateRange); -[@mel.obj] -external makeProps: - ( - ~mode: 'mode, - ~onSelect: - [@mel.unwrap] [ - | `Single(singleDate => unit) - | `Multiple(multipleDate => unit) - | `Range(rangeDate => unit) - ], - ~selected: - [@mel.unwrap] [ - | `Single(singleDate) - | `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=?, - ~key: string=?, - unit - ) => - { - . - "mode": 'mode, - "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, +type selected = [ + | `Single(singleDate) + | `Multiple(multipleDate) + | `Range(rangeDate) +]; + +type onSelect = [ + | `Single(singleDate => unit) + | `Multiple(multipleDate => unit) + | `Range(rangeDate => unit) +]; + +[@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": captionLayout, - "navLayout": navLayout, - "disableNavigation": bool, - "hideNavigation": bool, - "animate": bool, - "fixedWeeks": bool, - "footer": footer, - "hideWeekdays": bool, - "numberOfMonths": int, - "reverseMonths": bool, - "pagedNavigation": bool, - "showOutsideDays": bool, - "showWeekNumber": bool, - } => - React.element = - "DayPicker"; + type jsRangeDate = Js.Undefined.t(jsDateRange); + + /* react-day-picker expects the raw value, not a variant wrapper */ + type jsSelected = Js.Json.t; + + type jsOnSelect = Js.Json.t; + + 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) + }; + + /* Convert our polymorphic variant to the raw value that react-day-picker expects */ + let toJsSelected = (value: selected): jsSelected => + switch (value) { + | `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) => Obj.magic(callback) + | `Multiple(callback) => Obj.magic(callback) + | `Range(callback) => + Obj.magic((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/ReactDayPickerNative.re b/ReactDayPickerNative.re new file mode 100644 index 0000000..3435921 --- /dev/null +++ b/ReactDayPickerNative.re @@ -0,0 +1,1425 @@ +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)) { + monthNames[index]; + } else { + ""; + }; + +let newDate = (~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., + ~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 => { + 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) + && 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(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)) { + weekdayLabel[index]; + } else { + ""; + }; +let getWeekdayShort = (index: int): string => + if (index >= 0 && index < Array.length(weekdayShort)) { + 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 + } + } + }; +}; + +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 + | RangeMiddle + | RangeEnd + | RangeStartAndEnd; + +let getRangePosition = + (date: Js.Date.t, selected: option(selected)): rangePosition => { + switch (selected) { + | Some(`Range(optionRange)) => + switch (Js.Undefined.toOption(optionRange)) { + | Some(rangeValue) => + 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 + } + | _ => 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, ~isSelected: bool): string => { + let prefix = if (isToday) {"Today, "} else {""}; + let suffix = if (isSelected) {", selected"} else {""}; + prefix + ++ getWeekdayName(dayOfWeek(date)) + ++ ", " + ++ getMonthName(dateMonth(date)) + ++ " " + ++ string_of_int(dateDay(date)) + ++ ordinalSuffix(dateDay(date)) + ++ ", " + ++ string_of_int(dateYear(date)) + ++ suffix; +}; + +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]]) + | RangeStartAndEnd => + modifiers := List.concat([modifiers^, [classNames.dayRangeStart, 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, + ~focusTarget: option(Js.Date.t), + 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 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) { + 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([ + 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", dayIsFocusTarget ? 0 : (-1)), + ariaLabelProp( + formatAriaDayLabel( + day.date, + ~isToday=dayIsToday, + ~isSelected=dayIsSelected, + ), + ), + ], + [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, + ~multiSelectable: bool, + ~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 focusTarget = getInitialFocusTarget(weeks, selected, today); + let weekdayHeader = + hideWeekdays ? React.null : renderWeekdayHeader(showWeekNumber, ~animate); + let weekRows = + weeks + |> Array.map((week: calendarWeek) => + renderWeekRow( + week, + ~showWeekNum=showWeekNumber, + ~focusTarget, + 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", + multiSelectable ? "true" : "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", + multiSelectable ? "true" : "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 multiSelectable = currentMode == "multiple" || currentMode == "range"; + 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, + ~multiSelectable, + ~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..5aceba4 100644 --- a/dune +++ b/dune @@ -1,9 +1,9 @@ (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 server-reason-react.browser_ppx -js melange.ppx reason-react-ppx))) diff --git a/dune-project b/dune-project index fd1c9e3..a314492 100644 --- a/dune-project +++ b/dune-project @@ -15,6 +15,11 @@ (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") @@ -24,9 +29,10 @@ melange reason melange-webapi - ppxlib reason-react-ppx - reason-react) + 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 new file mode 100644 index 0000000..64d0051 --- /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 `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 + +```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/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..2bed194 --- /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) => Js.Undefined.return(d) + | None => Js.Undefined.fromOption(None) + }; + `Single(date); + | "multiple" => + let dates = + switch (config.multipleDates) { + | Some(d) => Js.Undefined.return(d) + | None => Js.Undefined.fromOption(None) + }; + `Multiple(dates); + | "range" => + let range = + switch (config.rangeFrom) { + | Some(from) => + switch (config.rangeTo) { + | Some(to_) => + Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(from), + ReactDayPicker.to_: Js.Undefined.return(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/JsRenderer.re b/example/js/JsRenderer.re new file mode 100644 index 0000000..fe468ed --- /dev/null +++ b/example/js/JsRenderer.re @@ -0,0 +1,5 @@ +let rendered = ReactDOMServer.renderToString(ExampleDayPickers.root); + +let () = print_endline("RENDER_START"); +let () = print_endline(rendered); +let () = print_endline("RENDER_END"); 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/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..3f765d7 --- /dev/null +++ b/example/native/NativeRenderer.re @@ -0,0 +1,5 @@ +let rendered = ReactDOM.renderToString(ExampleDayPickers.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/native/dune b/example/native/dune new file mode 100644 index 0000000..b267543 --- /dev/null +++ b/example/native/dune @@ -0,0 +1,11 @@ +(copy_files# "../shared/*.re") + +(executables + (names NativeRenderer RangeNativeRenderer) + (preprocess + (pps server-reason-react.ppx)) + (libraries + reason-react-day-picker.native + server-reason-react.react + server-reason-react.reactDom + server-reason-react.js)) diff --git a/example/shared/ExampleDayPickers.re b/example/shared/ExampleDayPickers.re new file mode 100644 index 0000000..191292f --- /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(Js.Undefined.return(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(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)) { + | 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/RangeRenderer.re b/example/shared/RangeRenderer.re new file mode 100644 index 0000000..e70560b --- /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(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(from), + ReactDayPicker.to_: Js.Undefined.return(to_), + })); + | None => + `Range(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.return(from), + ReactDayPicker.to_: Js.Undefined.fromOption(None), + })); + }; + | None => + switch (config.rangeTo) { + | Some(to_) => + `Range(Js.Undefined.return({ + ReactDayPicker.from: Js.Undefined.fromOption(None), + ReactDayPicker.to_: Js.Undefined.return(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); +}; 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..77ea72b --- /dev/null +++ b/example/shared/SharedFixture.re @@ -0,0 +1,7 @@ +/* Demo dates are now derived from today's date in ExampleDayPickers.re */ + +let numberOfMonths = 1; +let showOutsideDays = true; +let showWeekNumber = true; +let hideWeekdays = false; +let footerText = "Shared native/js sample"; 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..46f6f61 100644 --- a/reason-react-day-picker.opam +++ b/reason-react-day-picker.opam @@ -15,9 +15,10 @@ depends: [ "melange" "reason" "melange-webapi" - "ppxlib" "reason-react-ppx" "reason-react" + "server-reason-react" + "alcotest" {with-test} "odoc" {with-doc} ] build: [ @@ -37,3 +38,6 @@ build: [ dev-repo: "git+https://github.com/Software-Deployed/reason-react-day-picker.git" x-maintenance-intent: ["(latest)"] +pin-depends: [ + [ "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 new file mode 100644 index 0000000..ea78c0d --- /dev/null +++ b/reason-react-day-picker.opam.template @@ -0,0 +1,3 @@ +pin-depends: [ + [ "server-reason-react.dev" "git+https://github.com/ml-in-barcelona/server-reason-react.git" ] +]