├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── elm.json ├── examples ├── elm.json ├── index.css ├── index.html ├── index.js └── src │ ├── DurationDatePickerExample.elm │ ├── Main.elm │ ├── ModalPickerExample.elm │ ├── SingleDatePickerExample.elm │ └── Utilities.elm ├── package-lock.json ├── package.json ├── src ├── DatePicker │ ├── Alignment.elm │ ├── DateInput.elm │ ├── DurationUtilities.elm │ ├── Icons.elm │ ├── Settings.elm │ ├── SingleUtilities.elm │ ├── Theme.elm │ ├── Utilities.elm │ └── ViewComponents.elm ├── DurationDatePicker.elm ├── SingleDatePicker.elm └── Task │ └── Extra.elm └── tests ├── AlignmentTest.elm ├── DateInputTest.elm ├── DurationUtilitiesTest.elm ├── SingleUtilitiesTest.elm └── UtilitiesTest.elm /.gitignore: -------------------------------------------------------------------------------- 1 | # compiled Elm 2 | elm-stuff 3 | 4 | # compiled example.js files 5 | /examples/Compiled/ 6 | 7 | # node modules 8 | /node_modules/ 9 | 10 | # parcel 11 | .parcel-cache 12 | 13 | dist -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | NOTE: as is the case in the README, all code snippets below are specific to the `SingleDatePicker`; however, the only real difference between the `SingleDatePicker` and `DurationDatePicker` from an API standpoint is the `Msg` that a user needs to define to handle updates. Keep this in mind when making updates to your code. 4 | 5 | ## [11.0.0] 6 | 7 | ### **MAJOR/BREAKING CHANGES** 8 | - `openPicker` and `update` methods now return a `Cmd msg` 9 | - Updates to the`Theme` 10 | - The `Theme` has been extracted into a separate module. Use it like this now: 11 | ```elm 12 | import SingleDatePicker 13 | import DatePicker.Settings exposing (Settings, defaultSettings) 14 | import DatePicker.Theme exposing (Theme, defaultTheme) 15 | ``` 16 | - New properties have been added to the `Theme` (Check the Readme for more informations). 17 | - For technical reasons, all `Css.px` properties have been replaced with `Float` in order to make calculations with the provided values. 18 | - Removed `DomLocation` and `openPickerOutsideHierarchy` functions. The pickers are now always positioned `fixed` by default. The `openPicker` method requires a `triggerElementId`. The picker will be positioned automatically aligned to the passed id's element. 19 | 20 | ```elm 21 | update : Msg -> Model -> ( Model, Cmd Msg ) 22 | update msg model = 23 | case msg of 24 | ... 25 | 26 | OpenPicker triggerElementId -> 27 | let 28 | ( newPicker, pickerCmd ) = 29 | SingleDatePicker.openPicker triggerElementId 30 | (userDefinedDatePickerSettings model.zone model.today) 31 | model.today 32 | model.pickedTime 33 | model.picker 34 | in 35 | ( { model | picker = newPicker }, pickerCmd ) 36 | 37 | view : Model -> Html Msg 38 | view model = 39 | ... 40 | div [] 41 | [ button [ id "my-button", onClick (OpenPicker "my-button") ] [ text "Open Me!" ] 42 | , DatePicker.view userDefinedDatePickerSettings model.picker 43 | ] 44 | ``` 45 | - Added an `Alignment` module, providing utilities for determining the alignment and layout of a date picker component, ensuring proper positioning relative to its trigger element and viewport constraints. This replaces the `DomLocation` and `outsideHierarchy` utilities. 46 | - Added a `DateInput` module and connect it with both pickers. Use it like this: 47 | ```elm 48 | SingleDatePicker.viewDateInput [] 49 | (userDefinedDatePickerSettings model.zone) 50 | model.today 51 | model.dateInputPickerTime 52 | model.dateInputPicker 53 | ``` 54 | or 55 | ```elm 56 | DurationDatePicker.viewDurationInput [] 57 | (userDefinedDatePickerSettings model.zone) 58 | model.today 59 | model.dateInputPickerTime 60 | model.dateInputPicker 61 | ``` 62 | - No `OpenPicker` Msg is required with that variant. 63 | - See Readme and module documentation for all usage and configuration options. 64 | 65 | 66 | ## [10.0.2] 67 | 68 | ### **CHANGED** 69 | - Add `elm-css` CSS class to container element to distinguish from older versions 70 | 71 | ## [10.0.1] 72 | 73 | ### **CHANGED** 74 | - Some CSS classNames and ids have been renamed or re-added so that most of the elements' selectors can be targeted the same way as before elm-css was introduced (this was needed for testing purposes for example). 75 | 76 | ## [10.0.0] 77 | 78 | ### **MAJOR/BREAKING CHANGES** 79 | - All view functions have been moved to a shared module to reduce duplicate code between the `SingleDatePicker` and `DurationDatePicker`. 80 | - The CSS is now defined in a built-in way with `elm-css`, all separately distributed styles have been removed. The CSS classes are still available and attached to all of the components. So in case more individual styling is needed, you can still use the classes – even though the markup and classNames might have changed. 81 | - To allow custom styling, there's a newly created `Theme` as part of the `Settings`. A predefined `defaultTheme` is included in the `defaultSettings` but as all other settings it can be overwritten (see README.md). 82 | - Also the `Settings` have been unified for both pickers and extracted to a shared module. The `Settings` is now an `exposed-module` that needs to be imported separately when using the date pickers like this: 83 | 84 | ```elm 85 | import DatePicker.Settings 86 | exposing 87 | ( Settings 88 | , TimePickerVisibility(..) 89 | , defaultSettings 90 | , defaultTimePickerSettings 91 | ) 92 | import SingleDatePicker 93 | ``` 94 | - The examples have been moved to a parcel app to simplify local development. Simply run `npm install` and `npm start` to run the examples locally. 95 | 96 | 97 | ## [9.0.1] 98 | 99 | ### **CHANGED** 100 | - Improved positioning of datepickers opened outside hierarchy. Based on the viewport size and position of the trigger element the datepicker popover will be aligned in a predefined priority: 1. align left of trigger element, 2. align right of trigger element, 3. align center of trigger element, 4. align left of viewport 101 | - Fix a few general styles for new presets 102 | 103 | 104 | ## [9.0.0] 105 | 106 | ### **ADDED** 107 | New fields have been added to the `Settings` to receive date or date range presets. Example date range presets have been added to the README and the Duration example. 108 | 109 | ## [8.0.1] 110 | 111 | - Fix time picker visibility for SingleDatePicker 112 | 113 | ## [8.0.0] 114 | 115 | ### **ADDED** 116 | Added functions to render the picker outside the DOM hierarchy while positioning it manually attached to a trigger element (`Datepicker.openPickerOutsideDomHierarchy` and `Datepicker.updatePickerPosition`). 117 | Also added a new example for that use case. See `BasicModal` example and README. 118 | 119 | ## [7.0.2] 120 | 121 | - Removed duplicate `#month` ID selector for SingleDatePicker 122 | 123 | ## [7.0.1] 124 | 125 | - Fixed a bug where the time was not visible for whole days (00:00 - 23:59) 126 | 127 | ## [7.0.0] 128 | 129 | ### **MAJOR/BREAKING CHANGE** 130 | 131 | Previously, the DatePicker was updated in the view, and the updated DatePicker was then passed to the user's Msg. This could result in race conditions generating incorrect selection data. In order to prevent undesirable behavior as a result, the DatePicker should now be updated from the update function. This will result in a breaking change for any user, but should be relatively straightforward to fix. 132 | 133 | The signature of the provided Msg for the DatePicker has changed: 134 | 135 | ```elm 136 | -- from something like: 137 | | UpdatePicker ( DatePicker.DatePicker, Maybe Posix ) 138 | 139 | -- to now something like: 140 | | UpdatePicker SingleDatePicker.Msg 141 | ``` 142 | 143 | The implementation of the Msg now needs to call the update of the DatePicker to get the updated picker and the current selection: 144 | 145 | ```elm 146 | -- from something like: 147 | UpdatePicker ( newPicker, maybeNewTime ) -> 148 | ( { model | picker = newPicker, pickedTime = Maybe.map (\t -> Just t) maybeNewTime |> Maybe.withDefault model.pickedTime }, Cmd.none ) 149 | 150 | -- to now something like: 151 | UpdatePicker subMsg -> 152 | let 153 | ( newPicker, maybeNewTime ) = 154 | SingleDatePicker.update (userDefinedDatePickerSettings model.zone model.currentTime) subMsg model.picker 155 | in 156 | ( { model | picker = newPicker, pickedTime = Maybe.map (\t -> Just t) maybeNewTime |> Maybe.withDefault model.pickedTime }, Cmd.none ) 157 | ``` 158 | 159 | Additionally, the internalMsg is no longer provided on the `Settings` but instead provided on the `init` call for the DatePicker. 160 | 161 | ### Thanks to [patbro](https://github.com/patbro) for pointing this out. 162 | 163 | ## [6.0.0] 164 | 165 | ### **ADDED** 166 | 167 | To keep track of the calendar weeks when picking dates, calendar week numbers have been added to the pickers as a separate column. This feature is optional and can be enabled with the new `Settings` entry: 168 | 169 | ```elm 170 | type alias Settings msg = 171 | { {- [...] -} 172 | , showCalendarWeekNumbers : Bool 173 | } 174 | ``` 175 | 176 | ## [5.0.2] 177 | 178 | ### **CHANGED** 179 | 180 | To improve the visual design the markup and styling have been reworked. The files `SingleDatePicker.css` and `DurationDatePicker.css` have been merged and replaced by a shared CSS resource – `DateTimePicker.css` – for both picker variants (CSS classnames and -prefixes may have changed or extended during that process). 181 | Also, the most important design tokens (sizes, colors, etc.) have been outsourced into CSS variables and collected in a theme file (`DateTimePickerTheme.css`) to allow easy styling customization. 182 | 183 | ## [5.0.1] 184 | 185 | Automatically sets the lastest seconds when selecting an end date in the `DurationDatePicker` (e.g. 12:30:59). 186 | 187 | ## [4.0.1] 188 | 189 | ### **PATCH** 190 | 191 | Fix `DurationDatePicker` docs. 192 | 193 | ## [4.0.0] 194 | 195 | ### **MAJOR/BREAKING CHANGE** 196 | 197 | To allow greater flexibility regarding time picker visibility the dateTime picker `Settings` have been reworked. 198 | 199 | Old settings: 200 | 201 | ```elm 202 | type alias Settings msg = 203 | { internalMsg : ( DatePicker, Maybe Posix ) -> msg 204 | , zone : Zone 205 | , formattedDay : Weekday -> String 206 | , formattedMonth : Month -> String 207 | , focusedDate : Maybe Posix 208 | , dateTimeProcessor : 209 | { isDayDisabled : Zone -> Posix -> Bool 210 | , allowedTimesOfDay : 211 | Zone 212 | -> Posix 213 | -> 214 | { startHour : Int 215 | , startMinute : Int 216 | , endHour : Int 217 | , endMinute : Int 218 | } 219 | } 220 | , dateStringFn : Zone -> Posix -> String 221 | , timeStringFn : Zone -> Posix -> String 222 | , isFooterDisabled : Bool 223 | , isFullDayEnabled : Bool 224 | } 225 | ``` 226 | 227 | New settings: 228 | 229 | ```elm 230 | type alias Settings msg = 231 | { internalMsg : ( DatePicker, Maybe Posix ) -> msg 232 | , zone : Zone 233 | , formattedDay : Weekday -> String 234 | , formattedMonth : Month -> String 235 | , isDayDisabled : Zone -> Posix -> Bool 236 | , focusedDate : Maybe Posix 237 | , dateStringFn : Zone -> Posix -> String 238 | , timePickerVisibility : TimePickerVisibility 239 | } 240 | 241 | type TimePickerVisibility 242 | = NeverVisible 243 | | Toggleable TimePickerSettings 244 | | AlwaysVisible TimePickerSettings 245 | 246 | type alias TimePickerSettings = 247 | { timeStringFn : Zone -> Posix -> String 248 | , allowedTimesOfDay : 249 | Zone 250 | -> Posix 251 | -> 252 | { startHour : Int 253 | , startMinute : Int 254 | , endHour : Int 255 | , endMinute : Int 256 | } 257 | } 258 | ``` 259 | 260 | The `TimePickerVisibility` combined with `TimePickerSettings` have replaced the `dateTimeProcessor` field from the prior versions. The `isFooterDisabled` & `isFullDayEnabled` fields which had previously controlled time picker visibility & helped determine time boundaries for a selected day have been removed entirely. 261 | 262 | Users can now choose to have the time picker `NeverVisible`, `Toggleable`, or `AlwaysVisible`. All fields related to time (`timeStringFn` & `allowedTimesOfDay`) have been moved to `TimePickerSettings`, which only need to be provided for the cases in which the time picker is (potentially) visible. It is worth noting that even when the time picker is hidden/`NeverVisible` a day within the picker runs from `00:00` to `23:59`. When a selection is made in this case, the start of the selected day, `00:00`, is returned as the time of the selected day. 263 | 264 | ### **MAJOR/BREAKING CHANGE** 265 | 266 | The `openPicker` function for both modules now expects `Settings` as the first argument instead of a `Time.Zone`. 267 | 268 | Old: 269 | 270 | ```elm 271 | openPicker : Zone -> Posix -> Maybe Posix -> DatePicker -> DatePicker 272 | ``` 273 | 274 | New: 275 | 276 | ```elm 277 | openPicker : Settings msg -> Posix -> Maybe Posix -> DatePicker -> DatePicker 278 | ``` 279 | 280 | ## [3.0.0] 281 | 282 | ### **MAJOR/BREAKING CHANGE** 283 | 284 | Two fields have been added to the `Settings` type: `isFooterDisabled` & `isFullDayEnabled`. `isFooterDisabled` allows the time picker in the dateTime picker to be hidden, ostensibly allowing a user to select just a day and 285 | not necessarily a time of day. The `isFullDayEnabled` field was introduced to ensure that, when the time picker is hidden, a day on the picker day would start at `00:00` and end at `23:59`. 286 | 287 | Old settings: 288 | 289 | ```elm 290 | type alias Settings msg = 291 | { internalMsg : ( DatePicker, Maybe Posix ) -> msg 292 | , zone : Zone 293 | , formattedDay : Weekday -> String 294 | , formattedMonth : Month -> String 295 | , focusedDate : Maybe Posix 296 | , dateTimeProcessor : 297 | { isDayDisabled : Zone -> Posix -> Bool 298 | , allowedTimesOfDay : 299 | Zone 300 | -> Posix 301 | -> 302 | { startHour : Int 303 | , startMinute : Int 304 | , endHour : Int 305 | , endMinute : Int 306 | } 307 | } 308 | , dateStringFn : Zone -> Posix -> String 309 | , timeStringFn : Zone -> Posix -> String 310 | } 311 | ``` 312 | 313 | New settings: 314 | 315 | ```elm 316 | type alias Settings msg = 317 | { internalMsg : ( DatePicker, Maybe Posix ) -> msg 318 | , zone : Zone 319 | , formattedDay : Weekday -> String 320 | , formattedMonth : Month -> String 321 | , focusedDate : Maybe Posix 322 | , dateTimeProcessor : 323 | { isDayDisabled : Zone -> Posix -> Bool 324 | , allowedTimesOfDay : 325 | Zone 326 | -> Posix 327 | -> 328 | { startHour : Int 329 | , startMinute : Int 330 | , endHour : Int 331 | , endMinute : Int 332 | } 333 | } 334 | , dateStringFn : Zone -> Posix -> String 335 | , timeStringFn : Zone -> Posix -> String 336 | , isFooterDisabled : Bool 337 | , isFullDayEnabled : Bool 338 | } 339 | ``` 340 | 341 | ## [2.0.3] 342 | 343 | ### **PATCH** 344 | 345 | Add html `id`s/`class`es to `DurationDatePicker` elements for testing. 346 | 347 | ## [2.0.2] 348 | 349 | ### **PATCH** 350 | 351 | Add html `id`s/`class`es to `SingleDatePicker` elements for testing. 352 | 353 | ## [2.0.1] 354 | 355 | ### **PATCH** 356 | 357 | Fix time of day validation for both pickers. 358 | 359 | ## [2.0.0] 360 | 361 | ### **MAJOR/BREAKING CHANGE** 362 | 363 | The dateTime picker API has been simplified while also allowing the selection of partial days (limiting times of day that are selectable for a given day). This has led to major changes in the `Settings` type and the way that picker updates are processed. 364 | 365 | #### User defined Msg 366 | 367 | Previously, an implementor needed to define two `Msg`s: one to handle picker updates & another to handle when a selection has been made. Now, only one message needs to be defined that expects a `Tuple` containing an updated picker instance and a `Maybe Posix` representing the selected datetime. 368 | 369 | So where an implementor's `Msg`s may have previously looked like this: 370 | 371 | ```elm 372 | type Msg 373 | = ... 374 | | Selected Posix 375 | | UpdatePicker SingleDatePicker.DatePicker 376 | ``` 377 | 378 | Now they look like this: 379 | 380 | ```elm 381 | type Msg 382 | = ... 383 | | UpdatePicker ( SingleDatePicker.DatePicker, Maybe Posix ) 384 | ``` 385 | 386 | #### Settings changes 387 | 388 | The `today` has been renamed to `focusedDate`. 389 | 390 | New functions: 391 | `allowedTimesOfDay` - determine the selectable time boundaries for a given `Zone` and day (`Posix`) 392 | `dateStringFn` - function to represent the selected day 393 | `timeStringFn` - function to represent the selected time of day 394 | 395 | Additionally, the dateTime picker now takes time zones into account. As such, the `Settings` type now expects a `Time.Zone` to be provided to it. The `defaultSettings` fn, `allowedTimesOfDay` fn, `dateStringFn`, and `timeStringFn` all require a `Time.Zone` to be passed as the first argument. 396 | 397 | Old settings: 398 | 399 | ```elm 400 | type alias Settings msg = 401 | { internalMsg : DatePicker -> msg 402 | , selectedMsg : Posix -> msg 403 | , formattedDay : Weekday -> String 404 | , formattedMonth : Month -> String 405 | , today : Maybe Posix 406 | , dayDisabled : Posix -> Bool 407 | } 408 | 409 | type alias MsgConfig msg = 410 | { internalMsg : DatePicker -> msg 411 | , externalMsg : Posix -> msg 412 | } 413 | 414 | defaultSettings : MsgConfig msg -> Settings msg 415 | ``` 416 | 417 | New settings: 418 | 419 | ```elm 420 | type alias Settings msg = 421 | { internalMsg : ( DatePicker, Maybe Posix ) -> msg 422 | , zone : Zone 423 | , formattedDay : Weekday -> String 424 | , formattedMonth : Month -> String 425 | , focusedDate : Maybe Posix 426 | , dateTimeProcessor : 427 | { isDayDisabled : Zone -> Posix -> Bool 428 | , allowedTimesOfDay : 429 | Zone 430 | -> Posix 431 | -> 432 | { startHour : Int 433 | , startMinute : Int 434 | , endHour : Int 435 | , endMinute : Int 436 | } 437 | } 438 | , dateStringFn : Zone -> Posix -> String 439 | , timeStringFn : Zone -> Posix -> String 440 | } 441 | 442 | defaultSettings : Zone -> (( DatePicker, Maybe Posix ) -> msg) -> Settings msg 443 | ``` 444 | 445 | ### **MAJOR/BREAKING CHANGE** 446 | 447 | The `openPicker` function for both modules now expects `Time.Zone` as the first argument. 448 | 449 | Old: 450 | 451 | ```elm 452 | openPicker : Posix -> Maybe Posix -> DatePicker -> DatePicker 453 | ``` 454 | 455 | New: 456 | 457 | ```elm 458 | openPicker : Zone -> Posix -> Maybe Posix -> DatePicker -> DatePicker 459 | ``` 460 | 461 | ### **MAJOR/BREAKING CHANGE** 462 | 463 | The `subscriptions` function for both modules now expects the configured `Settings` as the first argument. 464 | 465 | Old: 466 | 467 | ```elm 468 | subscriptions : (( DatePicker, Maybe Posix ) -> msg) -> DatePicker -> Sub msg 469 | ``` 470 | 471 | New: 472 | 473 | ```elm 474 | subscriptions : Settings msg -> (( DatePicker, Maybe Posix ) -> msg) -> DatePicker -> Sub msg 475 | ``` 476 | 477 | ### Additional Minor changes 478 | 479 | Added utility functions to examples to avoid compilation errors. 480 | 481 | ## [1.1.0] 482 | 483 | ### **MINOR CHANGE** 484 | 485 | Add `isOpen` function to both pickers to allow a user to query if the picker is open. 486 | 487 | ## [1.0.1] 488 | 489 | ### **PATCH** 490 | 491 | Add tests for Utilities 492 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Mercury Media Technology GmbH 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # elm-datetime-picker 2 | 3 | Single and duration datetime picker components written in Elm 0.19 4 | 5 | ## Install 6 | 7 | `elm install mercurymedia/elm-datetime-picker` 8 | 9 | ## In action 10 | 11 | #### Single Picker 12 | 13 | ![single-gif](https://github.com/user-attachments/assets/3ca259c0-9d7a-46b0-8979-c7dce07e7135) 14 | 15 | 16 | #### Duration Picker 17 | 18 | ![duration-gif](https://github.com/user-attachments/assets/96f66a23-097b-49b2-bd16-3fa28a162abe) 19 | 20 | 21 | ## Usage 22 | 23 | This package exposes two core modules, `SingleDatePicker` and `DurationDatePicker`. Three more modules are required / available for the configuration: `DatePicker.Settings`, `DatePicker.Theme` and `DatePicker.DateInput`. As their names imply, `SingleDatePicker` can be used to pick a singular datetime while `DurationDatePicker` is used to select a datetime range. To keep things simple, the documentation here focuses on the `SingleDatePicker` but both types have an example app for additional reference. 24 | 25 | You can use both picker variants with an integrated date input element – or without. 26 | 27 | ### Configure the Date Input Picker 28 | 29 | 1. Add the picker to the model and initialize it in the model init. One message needs to be defined that expects an internal `DatePicker` message. This is used to update the selection and view of the picker. 30 | 31 | ```elm 32 | import DatePicker.Settings exposing (Settings, defaultSettings) 33 | import SingleDatePicker as DatePicker 34 | 35 | type alias Model = 36 | { ... 37 | , picker : DatePicker.DatePicker Msg 38 | } 39 | 40 | type Msg 41 | = ... 42 | | UpdatePicker DatePicker.Msg 43 | 44 | init : ( Model, Cmd Msg ) 45 | init = 46 | ( { ... 47 | , picker = DatePicker.init UpdatePicker 48 | } 49 | , Cmd.none 50 | ) 51 | ``` 52 | 53 | 2. We call the `DatePicker.viewDateInput` function, passing it a `List (Html.Attribute msg)` to add custom attributes to the date input's container element, the picker `Settings`, the current time, the picked time and the `DatePicker` instance to be operated on. The minimal picker `Settings` only require a `Time.Zone` 54 | 55 | ```elm 56 | userDefinedDatePickerSettings : Zone -> Settings 57 | userDefinedDatePickerSettings timeZone = 58 | defaultSettings timeZone 59 | 60 | view : Model -> Html Msg 61 | view model = 62 | ... 63 | div [] 64 | [ DatePicker.viewDateInput [] 65 | (userDefinedDatePickerSettings model.zone) 66 | model.today 67 | model.dateInputPickerTime 68 | model.dateInputPicker 69 | ] 70 | ``` 71 | 72 | 3. Now it is time for the meat and potatoes: handling the `DatePicker` updates, including `saving` the time selected in the picker to the calling module's model. 73 | 74 | ```elm 75 | type alias Model = 76 | { ... 77 | , today : Posix 78 | , zone : Zone 79 | , pickedTime : Maybe Posix 80 | , picker : DatePicker.DatePicker 81 | } 82 | 83 | type Msg 84 | = ... 85 | | UpdatePicker DatePicker.Msg 86 | 87 | update : Msg -> Model -> ( Model, Cmd Msg ) 88 | update msg model = 89 | case msg of 90 | ... 91 | UpdatePicker subMsg -> 92 | let 93 | ( ( updatedPicker, maybeUpdatedTime ), pickerCmd ) = 94 | SingleDatePicker.update (userDefinedDatePickerSettings model.zone) subMsg model.picker 95 | in 96 | ( { model | picker = updatedPicker, pickedTime = maybeUpdatedTime }, pickerCmd ) 97 | ``` 98 | 99 | Remember that message we passed into the `DatePicker` settings? Here is where it comes into play. `UpdatePicker` let's us know that an update of the `DatePicker` instance's internal state needs to happen. To process the `DatePicker.Msg` you can pass it to the respective `DatePicker.update` function along with the `Settings` and the current `DatePicker` instance. That will then return us the updated `DatePicker` instance, to save in the model of the calling module. Additionally, we get a `Maybe Posix`. In the case of `Just` a time, we set that on the model as the new `pickedTime` otherwise we default to the current `pickedTime`. 100 | 101 | ### Configure the Picker without Date Input 102 | 103 | To use the picker without a date input in order to use is accross other use cases, you need to manually take care of the opening mechanism. 104 | 105 | 1. (Add initialization and `Update` method like in the previous date input variant) 106 | 107 | 2. We call the `DatePicker.view` function, passing it the picker `Settings` and the `DatePicker` instance to be operated on. The `Settings` are the same – but now you can add your own trigger element (e.g. a button). Make sure to give your trigger element an `id`, it needs to be passed to the picker later on. 108 | 109 | ```elm 110 | userDefinedDatePickerSettings : Zone -> Settings 111 | userDefinedDatePickerSettings timeZone = 112 | defaultSettings timeZone 113 | 114 | view : Model -> Html Msg 115 | view model = 116 | ... 117 | div [] 118 | [ button [ id "my-button", onClick (OpenPicker "my-button") ] [ text "Open Me!" ] 119 | , DatePicker.view userDefinedDatePickerSettings model.picker 120 | ] 121 | ``` 122 | 123 | 3. Handling the `DatePicker` updates works the same as in the date input variant – but as mentioned before, you now need to manually handle the opening: 124 | 125 | ```elm 126 | type alias Model = 127 | { ... 128 | , today : Posix 129 | , zone : Zone 130 | , pickedTime : Maybe Posix 131 | , picker : DatePicker.DatePicker 132 | } 133 | 134 | type Msg 135 | = ... 136 | | OpenPicker String 137 | | UpdatePicker DatePicker.Msg 138 | 139 | update : Msg -> Model -> ( Model, Cmd Msg ) 140 | update msg model = 141 | case msg of 142 | ... 143 | 144 | OpenPicker triggerElementId -> 145 | let 146 | ( newPicker, pickerCmd ) = 147 | SingleDatePicker.openPicker triggerElementId 148 | (userDefinedDatePickerSettings model.zone model.today) 149 | model.today 150 | model.pickedTime 151 | model.picker 152 | in 153 | ( { model | picker = newPicker }, pickerCmd ) 154 | 155 | UpdatePicker subMsg -> 156 | let 157 | (( updatedPicker, maybeNewTime ), pickerCmd) = 158 | SingleDatePicker.update (userDefinedDatePickerSettings model.zone model.today) subMsg model.picker 159 | in 160 | ( { model | picker = updatedPicker, pickedTime = Maybe.map (\t -> Just t) maybeNewTime |> Maybe.withDefault model.pickedTime }, pickerCmd ) 161 | ``` 162 | 163 | The user is responsible for defining his or her own `Open` picker message and placing the relevant event listener where he or she pleases. When handling this message in the `update` as seen above, we call `DatePicker.openPicker` which simply returns an updated picker instance to be stored on the model (`DatePicker.closePicker` is also provided and returns an updated picker instance like `openPicker` does). `DatePicker.openPicker` takes a `Zone` (the time zone in which to display the picker), `Posix` (the base time), a `Maybe Posix` (the picked time), and the `DatePicker` instance we wish to open. The base time is used to inform the picker what day it should center on in the event no datetime has been selected yet. This could be the current date or another date of the implementer's choosing. 164 | 165 | ## Automatically close the picker 166 | 167 | In the event you want the picker to close automatically when clicking outside of it, the module uses a subscription to determine when to close (outside of a save). Wire the picker subscription like below. 168 | 169 | ```elm 170 | subscriptions : Model -> Sub Msg 171 | subscriptions model = 172 | SingleDatePicker.subscriptions model.picker 173 | ``` 174 | 175 | ## Picker positions 176 | 177 | The picker's popover is positioned `fixed`. That is why you need need to pass an `id` of the trigger element when calling `DatePicker.openPicker`: the picker looks for the id's element and automatically aligns itself with that element's position. Based on the available space in each direction from the trigger element, the picker will try to find an alignment that prevents the element from being cut off by the viewport. 178 | 179 | 180 | ## Presets 181 | 182 | Date or date range presets can be added with the settings configuration. The list of presets is empty by default. 183 | 184 | ```elm 185 | type alias Settings = 186 | { -- [...] 187 | , presets : List Preset 188 | } 189 | ``` 190 | 191 | To configure presets, just add data of the following required type to the list: 192 | 193 | ```elm 194 | 195 | type Preset 196 | = PresetDate PresetDateConfig -- for single date pickers 197 | | PresetRange PresetRangeConfig -- for duration date pickers 198 | 199 | type alias PresetDateConfig = 200 | { title : String 201 | , date : Posix 202 | } 203 | 204 | type alias PresetRangeConfig = 205 | { title : String 206 | , range : { start : Posix, end : Posix } 207 | } 208 | ``` 209 | 210 | Here's an example: 211 | 212 | ```elm 213 | userDefinedDatePickerSettings : Zone -> Posix -> Settings 214 | userDefinedDatePickerSettings zone today = 215 | let 216 | defaults = 217 | defaultSettings zone 218 | in 219 | { defaults 220 | | presetRanges = 221 | [ { title = "This month" 222 | , range = 223 | { start = TimeExtra.floor Month zone today 224 | , end = 225 | TimeExtra.floor Month zone today 226 | |> TimeExtra.add Month 1 zone 227 | |> TimeExtra.add Day -1 zone 228 | } 229 | } 230 | ] 231 | } 232 | ``` 233 | 234 | ## Additional Configuration 235 | 236 | This is the settings type to be used when configuring the `DatePicker`. More configuration will be available in future releases. 237 | 238 | ```elm 239 | type alias Settings = 240 | { zone : Zone 241 | , id : String 242 | , firstWeekDay : Weekday 243 | , formattedDay : Weekday -> String 244 | , formattedMonth : Month -> String 245 | , isDayDisabled : Zone -> Posix -> Bool 246 | , focusedDate : Maybe Posix 247 | , dateStringFn : Zone -> Posix -> String 248 | , timePickerVisibility : TimePickerVisibility 249 | , showCalendarWeekNumbers : Bool 250 | , presets : List Preset 251 | , theme : Theme.Theme 252 | , dateInputSettings : DateInput.Settings 253 | } 254 | ``` 255 | 256 | 257 | ## Date Input & Configuration 258 | 259 | The `DateInput` module formats and validates a user's text input for dates and times. The following type is the `DateInput.Settings` type to be used when configuring the `DatePicker.Settings`. 260 | 261 | ```elm 262 | type alias Settings = 263 | { format : Format 264 | , getErrorMessage : InputError -> String 265 | } 266 | ``` 267 | 268 | To configure the `Format` there are a few options: 269 | 270 | ```elm 271 | {-| Either allow a date only or a date and a time. 272 | -} 273 | type Format 274 | = Date DateFormat 275 | | DateTime DateFormat TimeFormat 276 | 277 | 278 | {-| Configuration for date formats. 279 | -} 280 | type alias DateFormat = 281 | { pattern : DatePattern 282 | , separator : Char 283 | , placeholders : DateParts Char 284 | } 285 | 286 | 287 | {-| Configuration for time formats. 288 | -} 289 | type alias TimeFormat = 290 | { separator : Char 291 | , placeholders : TimeParts Char 292 | , allowedTimesOfDay : Zone -> Posix -> { startHour : Int, startMinute : Int, endHour : Int, endMinute : Int } 293 | } 294 | 295 | 296 | {-| Available date format patterns (defines the order of day, month and year). 297 | -} 298 | type DatePattern 299 | = DDMMYYYY 300 | | MMDDYYYY 301 | | YYYYMMDD 302 | | YYYYDDMM 303 | 304 | ``` 305 | 306 | So to configure your custom date format you can add the following properties to your picker's `Settings`: 307 | 308 | ```elm 309 | import DatePicker.Settings exposing (Settings, defaultSettings) 310 | import DatePicker.DateInput as DateInput 311 | 312 | -- [...] 313 | 314 | getErrorMessage : DateInput.InputError -> String 315 | getErrorMessage error = 316 | case error of 317 | DateInput.ValueInvalid -> 318 | "Invalid value. Make sure to use the correct format." 319 | 320 | DateInput.ValueNotAllowed -> 321 | "Date not allowed." 322 | 323 | DateInput.DurationInvalid -> 324 | "End date is before start date." 325 | 326 | 327 | userDefinedDatePickerSettings : Zone -> Posix -> Settings 328 | userDefinedDatePickerSettings zone today = 329 | let 330 | defaults = 331 | defaultSettings zone 332 | 333 | dateFormat = 334 | { pattern = DDMMYYYY 335 | , separator = '.' 336 | , placeholders = { day = 'd', month = 'm', year = 'y' } 337 | } 338 | 339 | timeFormat = 340 | { separator = ':' 341 | , placeholders = { hour = 'h', minute = 'm' } 342 | , allowedTimesOfDay = \_ _ -> { startHour = 0, startMinute = 0, endHour = 23, endMinute = 59 } 343 | } 344 | in 345 | { defaults 346 | | -- [...] 347 | , dateInputSettings = 348 | { format = DateInput.DateTime dateFormat timeFormat 349 | , getErrorMessage = getErrorMessage 350 | } 351 | } 352 | 353 | ``` 354 | 355 | As you can see in the code snippet above, the date input's validation can trigger three types of errors. You can define your own messages for each type. 356 | 357 | 358 | ## CSS & Theming 359 | 360 | The CSS for the date picker is now defined in a built-in way using [elm-css](https://package.elm-lang.org/packages/rtfeldman/elm-css/latest/). 361 | There are some design tokens that can be configured individually in a theme. 362 | In case you need to add additional styling, you can use the CSS-classes that are attached to all the components. You'll find a list of all classes under `/css/DateTimePicker.css`. 363 | 364 | In case you'd like to use the Theme, you can pass your custom theme to the `Settings`. The `Theme` record currently looks like this: 365 | 366 | ```elm 367 | type alias Theme = 368 | { fontSize : 369 | { base : Float 370 | , sm : Float 371 | , xs : Float 372 | , xxs : Float 373 | } 374 | , color : 375 | { text : 376 | { primary : Css.Color 377 | , secondary : Css.Color 378 | , disabled : Css.Color 379 | , error : Css.Color 380 | } 381 | , primary : 382 | { main : Css.Color 383 | , contrastText : Css.Color 384 | , light : Css.Color 385 | } 386 | , background : 387 | { container : Css.Color 388 | , footer : Css.Color 389 | , presets : Css.Color 390 | , input : Css.Color 391 | } 392 | , action : { hover : Css.Color } 393 | , border : Css.Color 394 | } 395 | , size : 396 | { presetsContainer : Float 397 | , day : Float 398 | , iconButton : Float 399 | , inputElement : Float 400 | } 401 | , spacing : { base : Float } 402 | , borderWidth : Float 403 | , borderRadius : 404 | { base : Float 405 | , lg : Float 406 | } 407 | , boxShadow : 408 | { offsetX : Float 409 | , offsetY : Float 410 | , blurRadius : Float 411 | , spreadRadius : Float 412 | , color : Css.Color 413 | } 414 | , zIndex : Int 415 | , transition : { duration : Float } 416 | , classNamePrefix : String 417 | } 418 | ``` 419 | 420 | Passing a customized theme to the settings works like this: 421 | 422 | ```elm 423 | import Css -- from elm-css 424 | import DatePicker.Settings exposing (Settings, defaultSettings) 425 | import DatePicker.Theme exposing (Theme, defaultTheme) 426 | 427 | -- [...] 428 | 429 | customTheme : Theme 430 | customTheme = 431 | { defaultTheme 432 | | color = 433 | { text = 434 | { primary = Css.hex "22292f" 435 | , secondary = Css.rgba 0 0 0 0.5 436 | , disabled = Css.rgba 0 0 0 0.25 437 | } 438 | , primary = 439 | { main = Css.hex "3490dc" 440 | , contrastText = Css.hex "ffffff" 441 | , light = Css.rgba 52 144 220 0.1 442 | } 443 | , background = 444 | { container = Css.hex "ffffff" 445 | , footer = Css.hex "ffffff" 446 | , presets = Css.hex "ffffff" 447 | } 448 | , action = { hover = Css.rgba 0 0 0 0.08 } 449 | , border = Css.rgba 0 0 0 0.1 450 | } 451 | } 452 | 453 | 454 | userDefinedDatePickerSettings : Zone -> Posix -> Settings 455 | userDefinedDatePickerSettings zone today = 456 | let 457 | defaults = 458 | defaultSettings zone 459 | in 460 | { defaults 461 | | -- [...] 462 | , theme = customTheme 463 | } 464 | 465 | ``` 466 | 467 | ## Examples 468 | 469 | Examples can be found in the [examples](https://github.com/mercurymedia/elm-datetime-picker/tree/master/examples) folder. To view the examples in the browser run `npm install` and `npm start` from the root of the repository. 470 | -------------------------------------------------------------------------------- /elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "package", 3 | "name": "mercurymedia/elm-datetime-picker", 4 | "summary": "a datetime picker component", 5 | "license": "MIT", 6 | "version": "11.0.0", 7 | "exposed-modules": [ 8 | "DurationDatePicker", 9 | "SingleDatePicker", 10 | "DatePicker.Settings", 11 | "DatePicker.Theme", 12 | "DatePicker.DateInput" 13 | ], 14 | "elm-version": "0.19.0 <= v < 0.20.0", 15 | "dependencies": { 16 | "elm/browser": "1.0.0 <= v < 2.0.0", 17 | "elm/core": "1.0.0 <= v < 2.0.0", 18 | "elm/html": "1.0.0 <= v < 2.0.0", 19 | "elm/json": "1.0.0 <= v < 2.0.0", 20 | "elm/svg": "1.0.1 <= v < 2.0.0", 21 | "elm/time": "1.0.0 <= v < 2.0.0", 22 | "elm-community/html-extra": "3.4.0 <= v < 4.0.0", 23 | "elm-community/list-extra": "8.7.0 <= v < 9.0.0", 24 | "justinmimbs/date": "4.1.0 <= v < 5.0.0", 25 | "justinmimbs/time-extra": "1.2.0 <= v < 2.0.0", 26 | "rtfeldman/elm-css": "18.0.0 <= v < 19.0.0" 27 | }, 28 | "test-dependencies": { 29 | "elm-explorations/test": "2.2.0 <= v < 3.0.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /examples/elm.json: -------------------------------------------------------------------------------- 1 | { 2 | "type": "application", 3 | "source-directories": [ 4 | "../src", 5 | "src" 6 | ], 7 | "elm-version": "0.19.1", 8 | "dependencies": { 9 | "direct": { 10 | "elm/browser": "1.0.2", 11 | "elm/core": "1.0.5", 12 | "elm/html": "1.0.0", 13 | "elm/json": "1.1.3", 14 | "elm/svg": "1.0.1", 15 | "elm/time": "1.0.0", 16 | "elm/url": "1.0.0", 17 | "elm-community/html-extra": "3.4.0", 18 | "elm-community/list-extra": "8.7.0", 19 | "justinmimbs/date": "4.1.0", 20 | "justinmimbs/time-extra": "1.2.0", 21 | "rtfeldman/elm-css": "18.0.0" 22 | }, 23 | "indirect": { 24 | "elm/parser": "1.1.0", 25 | "elm/virtual-dom": "1.0.3", 26 | "robinheghan/murmur3": "1.0.0", 27 | "rtfeldman/elm-hex": "1.0.0" 28 | } 29 | }, 30 | "test-dependencies": { 31 | "direct": {}, 32 | "indirect": { 33 | "elm/bytes": "1.0.8", 34 | "elm/file": "1.0.5" 35 | } 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /examples/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | } 4 | 5 | body { 6 | display: flex; 7 | margin: 0px; 8 | height: 100vh; 9 | overflow: hidden; 10 | font-family: 'Open Sans', sans-serif; 11 | font-size: 14px; 12 | } 13 | 14 | .sidebar { 15 | width: 300px; 16 | display: flex; 17 | flex-direction: column; 18 | padding: 0 2.5rem; 19 | color: #555555; 20 | } 21 | 22 | .sidebar>.header { 23 | margin-top: 1rem; 24 | font-size: 2.4rem; 25 | } 26 | 27 | .sidebar>.navigation { 28 | margin-top: 2rem; 29 | display: flex; 30 | flex-direction: column; 31 | } 32 | 33 | .navigation>div { 34 | color: black; 35 | font-weight: normal; 36 | } 37 | 38 | a { 39 | text-decoration: none; 40 | cursor: pointer; 41 | color: "#177fd6"; 42 | } 43 | 44 | .page { 45 | display: flex; 46 | flex: 0; 47 | flex-grow: 1; 48 | flex-shrink: 0; 49 | background-color: #f2f2f2; 50 | overflow: scroll; 51 | } 52 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Datetime picker - Examples 7 | 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /examples/index.js: -------------------------------------------------------------------------------- 1 | import "./index.css"; 2 | import { Elm } from "./src/Main.elm"; 3 | 4 | Elm.Main.init({ node: document.getElementById("app") }); 5 | -------------------------------------------------------------------------------- /examples/src/DurationDatePickerExample.elm: -------------------------------------------------------------------------------- 1 | module DurationDatePickerExample exposing (Model, Msg, init, subscriptions, update, view) 2 | 3 | import DatePicker.DateInput as DateInput 4 | import DatePicker.Settings exposing (Preset(..), Settings, TimePickerVisibility(..), defaultSettings, defaultTimePickerSettings) 5 | import DurationDatePicker 6 | import Html exposing (Html, button, div, h1, text) 7 | import Html.Attributes exposing (class, id, style) 8 | import Html.Events exposing (onClick) 9 | import Task 10 | import Time exposing (Month(..), Posix, Zone) 11 | import Time.Extra as TimeExtra exposing (Interval(..)) 12 | import Utilities exposing (adjustAllowedTimesOfDayToClientZone, isDateBeforeToday, posixToDateString, posixToTimeString) 13 | 14 | 15 | type Msg 16 | = OpenDetachedPicker String 17 | | UpdateDetachedPicker DurationDatePicker.Msg 18 | | UpdateDateInputPicker DurationDatePicker.Msg 19 | | AdjustTimeZone Zone 20 | | Tick Posix 21 | 22 | 23 | type alias Model = 24 | { currentTime : Posix 25 | , zone : Zone 26 | , detachedPickerStart : Maybe Posix 27 | , detachedPickerEnd : Maybe Posix 28 | , detachedPicker : DurationDatePicker.DatePicker Msg 29 | , dateInputPickerStart : Maybe Posix 30 | , dateInputPickerEnd : Maybe Posix 31 | , dateInputPicker : DurationDatePicker.DatePicker Msg 32 | } 33 | 34 | 35 | update : Msg -> Model -> ( Model, Cmd Msg ) 36 | update msg model = 37 | case msg of 38 | OpenDetachedPicker elementId -> 39 | let 40 | ( newPicker, cmd ) = 41 | DurationDatePicker.openPicker elementId (userDefinedDatePickerSettings model.zone model.currentTime) model.currentTime model.detachedPickerStart model.detachedPickerEnd model.detachedPicker 42 | in 43 | ( { model | detachedPicker = newPicker }, cmd ) 44 | 45 | UpdateDetachedPicker subMsg -> 46 | let 47 | ( ( newPicker, maybeRuntime ), cmd ) = 48 | DurationDatePicker.update (userDefinedDatePickerSettings model.zone model.currentTime) subMsg model.detachedPicker 49 | 50 | ( startTime, endTime ) = 51 | Maybe.map (\( start, end ) -> ( Just start, Just end )) maybeRuntime |> Maybe.withDefault ( Nothing, Nothing ) 52 | in 53 | ( { model | detachedPicker = newPicker, detachedPickerStart = startTime, detachedPickerEnd = endTime }, cmd ) 54 | 55 | UpdateDateInputPicker subMsg -> 56 | let 57 | ( ( newPicker, maybeRuntime ), cmd ) = 58 | DurationDatePicker.update (userDefinedDatePickerSettings model.zone model.currentTime) subMsg model.dateInputPicker 59 | 60 | ( startTime, endTime ) = 61 | Maybe.map (\( start, end ) -> ( Just start, Just end )) maybeRuntime |> Maybe.withDefault ( Nothing, Nothing ) 62 | in 63 | ( { model | dateInputPicker = newPicker, dateInputPickerStart = startTime, dateInputPickerEnd = endTime }, cmd ) 64 | 65 | AdjustTimeZone newZone -> 66 | ( { model | zone = newZone }, Cmd.none ) 67 | 68 | Tick newTime -> 69 | ( { model | currentTime = newTime }, Cmd.none ) 70 | 71 | 72 | userDefinedDatePickerSettings : Zone -> Posix -> Settings 73 | userDefinedDatePickerSettings zone today = 74 | let 75 | defaults = 76 | defaultSettings zone 77 | 78 | allowedTimesOfDay = 79 | \clientZone datetime -> adjustAllowedTimesOfDayToClientZone Time.utc clientZone today datetime 80 | 81 | dateFormat = 82 | DateInput.defaultDateFormat 83 | 84 | timeFormat = 85 | DateInput.defaultTimeFormat 86 | 87 | dateInputSettings = 88 | { format = DateInput.DateTime dateFormat { timeFormat | allowedTimesOfDay = allowedTimesOfDay }, getErrorMessage = getErrorMessage } 89 | in 90 | { defaults 91 | | isDayDisabled = \clientZone datetime -> isDateBeforeToday (TimeExtra.floor Day clientZone today) datetime 92 | , focusedDate = Just today 93 | , dateStringFn = posixToDateString 94 | , timePickerVisibility = 95 | Toggleable 96 | { defaultTimePickerSettings 97 | | timeStringFn = posixToTimeString 98 | , allowedTimesOfDay = allowedTimesOfDay 99 | } 100 | , showCalendarWeekNumbers = True 101 | , dateInputSettings = dateInputSettings 102 | , presets = 103 | [ PresetRange 104 | { title = "Today" 105 | , range = 106 | { start = TimeExtra.floor Day zone today 107 | , end = TimeExtra.floor Day zone today 108 | } 109 | } 110 | , PresetRange 111 | { title = "This month" 112 | , range = 113 | { start = TimeExtra.floor Month zone today 114 | , end = 115 | TimeExtra.floor Month zone today 116 | |> TimeExtra.add Month 1 zone 117 | |> TimeExtra.add Day -1 zone 118 | } 119 | } 120 | , PresetRange 121 | { title = "Next month" 122 | , range = 123 | { start = 124 | TimeExtra.floor Month zone today 125 | |> TimeExtra.add Month 1 zone 126 | , end = 127 | TimeExtra.floor Month zone today 128 | |> TimeExtra.add Month 2 zone 129 | |> TimeExtra.add Day -1 zone 130 | } 131 | } 132 | , PresetRange 133 | { title = "Next 2 months" 134 | , range = 135 | { start = 136 | TimeExtra.floor Month zone today 137 | |> TimeExtra.add Month 1 zone 138 | , end = 139 | TimeExtra.floor Month zone today 140 | |> TimeExtra.add Month 3 zone 141 | |> TimeExtra.add Day -1 zone 142 | } 143 | } 144 | ] 145 | } 146 | 147 | 148 | getErrorMessage : DateInput.InputError -> String 149 | getErrorMessage error = 150 | case error of 151 | DateInput.ValueInvalid -> 152 | "Invalid value. Make sure to use the correct format." 153 | 154 | DateInput.ValueNotAllowed -> 155 | "Date not allowed." 156 | 157 | _ -> 158 | "End date is before start date." 159 | 160 | 161 | view : Model -> Html Msg 162 | view model = 163 | div 164 | [ style "width" "100%" 165 | , style "height" "100vh" 166 | , style "padding" "3rem" 167 | ] 168 | [ h1 [ style "margin-bottom" "1rem" ] [ text "DurationDatePicker Example" ] 169 | , div [ style "margin-bottom" "1rem" ] 170 | [ div [ style "margin-bottom" "1rem", style "position" "relative", style "width" "400px" ] 171 | [ div [ style "margin-bottom" "1rem" ] 172 | [ text "This is a duration input picker" ] 173 | , DurationDatePicker.viewDurationInput [] 174 | (userDefinedDatePickerSettings model.zone model.currentTime) 175 | model.currentTime 176 | model.dateInputPickerStart 177 | model.dateInputPickerEnd 178 | model.dateInputPicker 179 | ] 180 | , div [ style "margin-bottom" "1rem" ] 181 | [ text "This is a duration picker" ] 182 | , div [ style "display" "flex", style "gap" "1rem", style "align-items" "center" ] 183 | [ div [ style "position" "relative" ] 184 | [ button [ id "my-button", onClick <| OpenDetachedPicker "my-button" ] 185 | [ text "Open Picker" ] 186 | , DurationDatePicker.view (userDefinedDatePickerSettings model.zone model.currentTime) model.detachedPicker 187 | ] 188 | , Maybe.map2 189 | (\start end -> text (posixToDateString model.zone start ++ " " ++ posixToTimeString model.zone start ++ " - " ++ posixToDateString model.zone end ++ " " ++ posixToTimeString model.zone end)) 190 | model.detachedPickerStart 191 | model.detachedPickerEnd 192 | |> Maybe.withDefault (text "No date selected yet!") 193 | ] 194 | ] 195 | ] 196 | 197 | 198 | init : ( Model, Cmd Msg ) 199 | init = 200 | ( { currentTime = Time.millisToPosix 0 201 | , zone = Time.utc 202 | , detachedPickerStart = Nothing 203 | , detachedPickerEnd = Nothing 204 | , detachedPicker = DurationDatePicker.init UpdateDetachedPicker 205 | , dateInputPickerStart = Nothing 206 | , dateInputPickerEnd = Nothing 207 | , dateInputPicker = DurationDatePicker.init UpdateDateInputPicker 208 | } 209 | , Task.perform AdjustTimeZone Time.here 210 | ) 211 | 212 | 213 | subscriptions : Model -> Sub Msg 214 | subscriptions model = 215 | Sub.batch 216 | [ DurationDatePicker.subscriptions (userDefinedDatePickerSettings model.zone model.currentTime) model.detachedPicker 217 | , DurationDatePicker.subscriptions (userDefinedDatePickerSettings model.zone model.currentTime) model.dateInputPicker 218 | , Time.every 1000 Tick 219 | ] 220 | -------------------------------------------------------------------------------- /examples/src/Main.elm: -------------------------------------------------------------------------------- 1 | module Main exposing (main) 2 | 3 | import Browser exposing (Document) 4 | import Browser.Navigation as Nav 5 | import DurationDatePickerExample 6 | import Html exposing (Html, a, div, span, text) 7 | import Html.Attributes exposing (class, href, style, target) 8 | import ModalPickerExample 9 | import SingleDatePickerExample 10 | import Url exposing (Url) 11 | import Url.Parser as Parser 12 | 13 | 14 | 15 | -- MAIN 16 | 17 | 18 | main : Program () Model Msg 19 | main = 20 | Browser.application 21 | { init = init 22 | , onUrlChange = ChangedUrl 23 | , onUrlRequest = ClickedLink 24 | , subscriptions = subscriptions 25 | , update = update 26 | , view = view 27 | } 28 | 29 | 30 | type alias Model = 31 | { page : Page 32 | , navKey : Nav.Key 33 | } 34 | 35 | 36 | type Page 37 | = SingleDatePicker SingleDatePickerExample.Model 38 | | DurationDatePicker DurationDatePickerExample.Model 39 | | ModalPicker ModalPickerExample.Model 40 | | NotFound 41 | 42 | 43 | type Msg 44 | = ChangedUrl Url 45 | | ClickedLink Browser.UrlRequest 46 | | SingleDatePickerMsg SingleDatePickerExample.Msg 47 | | DurationDatePickerMsg DurationDatePickerExample.Msg 48 | | ModalPickerMsg ModalPickerExample.Msg 49 | 50 | 51 | 52 | -- MODEL 53 | 54 | 55 | init : () -> Url -> Nav.Key -> ( Model, Cmd Msg ) 56 | init _ url navKey = 57 | let 58 | initialModel = 59 | { page = NotFound, navKey = navKey } 60 | in 61 | changePageTo url initialModel 62 | 63 | 64 | 65 | -- SUBSCRIPTIONS 66 | 67 | 68 | subscriptions : Model -> Sub Msg 69 | subscriptions model = 70 | case model.page of 71 | NotFound -> 72 | Sub.none 73 | 74 | SingleDatePicker pickerModel -> 75 | Sub.map SingleDatePickerMsg (SingleDatePickerExample.subscriptions pickerModel) 76 | 77 | DurationDatePicker pickerModel -> 78 | Sub.map DurationDatePickerMsg (DurationDatePickerExample.subscriptions pickerModel) 79 | 80 | ModalPicker pickerModel -> 81 | Sub.map ModalPickerMsg (ModalPickerExample.subscriptions pickerModel) 82 | 83 | 84 | 85 | -- UPDATE 86 | 87 | 88 | update : Msg -> Model -> ( Model, Cmd Msg ) 89 | update msg model = 90 | case ( msg, model.page ) of 91 | ( ClickedLink urlRequest, _ ) -> 92 | case urlRequest of 93 | Browser.Internal url -> 94 | ( model 95 | , Nav.pushUrl model.navKey (Url.toString url) 96 | ) 97 | 98 | Browser.External href -> 99 | ( model 100 | , Nav.load href 101 | ) 102 | 103 | ( ChangedUrl url, _ ) -> 104 | changePageTo url model 105 | 106 | ( SingleDatePickerMsg subMsg, SingleDatePicker subModel ) -> 107 | let 108 | ( updatedSubModel, pageCmd ) = 109 | SingleDatePickerExample.update subMsg subModel 110 | in 111 | ( { model | page = SingleDatePicker updatedSubModel }, Cmd.map SingleDatePickerMsg pageCmd ) 112 | 113 | ( DurationDatePickerMsg subMsg, DurationDatePicker subModel ) -> 114 | let 115 | ( updatedSubModel, pageCmd ) = 116 | DurationDatePickerExample.update subMsg subModel 117 | in 118 | ( { model | page = DurationDatePicker updatedSubModel }, Cmd.map DurationDatePickerMsg pageCmd ) 119 | 120 | ( ModalPickerMsg subMsg, ModalPicker subModel ) -> 121 | let 122 | ( updatedSubModel, pageCmd ) = 123 | ModalPickerExample.update subMsg subModel 124 | in 125 | ( { model | page = ModalPicker updatedSubModel }, Cmd.map ModalPickerMsg pageCmd ) 126 | 127 | ( _, _ ) -> 128 | ( model, Cmd.none ) 129 | 130 | 131 | 132 | -- VIEW 133 | 134 | 135 | view : Model -> Document Msg 136 | view model = 137 | { title = "elm-datetime-picker" 138 | , body = 139 | [ viewSidebar 140 | , viewPage model.page 141 | ] 142 | } 143 | 144 | 145 | viewSidebar : Html Msg 146 | viewSidebar = 147 | div [ class "sidebar" ] 148 | [ viewHeader 149 | , viewSources 150 | , viewNavigation 151 | ] 152 | 153 | 154 | viewHeader : Html Msg 155 | viewHeader = 156 | div [ class "header" ] [ text "elm-datetime-picker" ] 157 | 158 | 159 | viewSources : Html Msg 160 | viewSources = 161 | div [ style "margin-top" "2rem" ] 162 | [ a 163 | [ href "https://github.com/mercurymedia/elm-datetime-picker" 164 | , target "_blank" 165 | ] 166 | [ text "Github" ] 167 | , span 168 | [ style "padding" "0px 8px" 169 | ] 170 | [ text "|" ] 171 | , a 172 | [ href "https://package.elm-lang.org/packages/mercurymedia/elm-datetime-picker/latest/" 173 | , target "_blank" 174 | ] 175 | [ text "Docs" ] 176 | ] 177 | 178 | 179 | viewNavigation : Html Msg 180 | viewNavigation = 181 | div [ class "navigation" ] 182 | [ div [] [ text "Examples" ] 183 | , viewPageLink "SingleDatePicker" "/" 184 | , viewPageLink "DurationDatePicker" "/duration" 185 | , viewPageLink "ModalPicker" "/modal" 186 | ] 187 | 188 | 189 | viewPageLink : String -> String -> Html Msg 190 | viewPageLink title url = 191 | a 192 | [ href url 193 | , style "margin-left" "15px" 194 | ] 195 | [ text title ] 196 | 197 | 198 | viewPage : Page -> Html Msg 199 | viewPage page = 200 | let 201 | toPage toMsg pageView = 202 | Html.map toMsg pageView 203 | in 204 | div 205 | [ class "page" 206 | ] 207 | [ case page of 208 | NotFound -> 209 | text "Not found" 210 | 211 | SingleDatePicker pageModel -> 212 | toPage SingleDatePickerMsg (SingleDatePickerExample.view pageModel) 213 | 214 | DurationDatePicker pageModel -> 215 | toPage DurationDatePickerMsg (DurationDatePickerExample.view pageModel) 216 | 217 | ModalPicker pageModel -> 218 | toPage ModalPickerMsg (ModalPickerExample.view pageModel) 219 | ] 220 | 221 | 222 | 223 | -- HELPER 224 | 225 | 226 | changePageTo : Url -> Model -> ( Model, Cmd Msg ) 227 | changePageTo url model = 228 | let 229 | toPage toModel toMsg ( pageModel, pageCmd ) = 230 | ( { model | page = toModel pageModel }, Cmd.map toMsg pageCmd ) 231 | 232 | parser = 233 | Parser.oneOf 234 | [ Parser.map (SingleDatePickerExample.init |> toPage SingleDatePicker SingleDatePickerMsg) Parser.top 235 | , Parser.map (DurationDatePickerExample.init |> toPage DurationDatePicker DurationDatePickerMsg) (Parser.s "duration") 236 | , Parser.map (ModalPickerExample.init |> toPage ModalPicker ModalPickerMsg) (Parser.s "modal") 237 | ] 238 | in 239 | Parser.parse parser url 240 | |> Maybe.withDefault ( { model | page = NotFound }, Cmd.none ) 241 | -------------------------------------------------------------------------------- /examples/src/ModalPickerExample.elm: -------------------------------------------------------------------------------- 1 | module ModalPickerExample exposing (Model, Msg, init, subscriptions, update, view) 2 | 3 | import Browser.Events 4 | import DatePicker.Settings as Settings exposing (Settings, TimePickerVisibility(..), defaultSettings, defaultTimePickerSettings) 5 | import Html exposing (Html, button, div, h1, text) 6 | import Html.Attributes exposing (class, id, style) 7 | import Html.Events exposing (onClick) 8 | import Json.Decode as Decode 9 | import SingleDatePicker 10 | import Task 11 | import Time exposing (Month(..), Posix, Zone) 12 | import Time.Extra as TimeExtra exposing (Interval(..)) 13 | import Utilities exposing (adjustAllowedTimesOfDayToClientZone, isDateBeforeToday, posixToDateString, posixToTimeString) 14 | 15 | 16 | type Msg 17 | = OpenPicker 18 | | UpdatePicker SingleDatePicker.Msg 19 | | AdjustTimeZone Zone 20 | | Tick Posix 21 | | OnViewportChange 22 | | ToggleModal 23 | | NoOp 24 | 25 | 26 | type alias Model = 27 | { currentTime : Posix 28 | , zone : Zone 29 | , pickedTime : Maybe Posix 30 | , picker : SingleDatePicker.DatePicker Msg 31 | , modalOpen : Bool 32 | } 33 | 34 | 35 | update : Msg -> Model -> ( Model, Cmd Msg ) 36 | update msg model = 37 | let 38 | pickerSettings = 39 | userDefinedDatePickerSettings model.zone model.currentTime 40 | in 41 | case msg of 42 | OpenPicker -> 43 | let 44 | ( newPicker, cmd ) = 45 | SingleDatePicker.openPicker "my-button" pickerSettings model.currentTime model.pickedTime model.picker 46 | in 47 | ( { model | picker = newPicker }, cmd ) 48 | 49 | UpdatePicker subMsg -> 50 | let 51 | ( ( newPicker, maybeNewTime ), cmd ) = 52 | SingleDatePicker.update pickerSettings subMsg model.picker 53 | in 54 | ( { model | picker = newPicker, pickedTime = Maybe.map (\t -> Just t) maybeNewTime |> Maybe.withDefault model.pickedTime }, cmd ) 55 | 56 | AdjustTimeZone newZone -> 57 | ( { model | zone = newZone }, Cmd.none ) 58 | 59 | Tick newTime -> 60 | ( { model | currentTime = newTime }, Cmd.none ) 61 | 62 | OnViewportChange -> 63 | let 64 | ( newPicker, cmd ) = 65 | SingleDatePicker.updatePickerPosition model.picker 66 | in 67 | ( { model | picker = newPicker }, cmd ) 68 | 69 | ToggleModal -> 70 | ( { model | modalOpen = not model.modalOpen }, Cmd.none ) 71 | 72 | NoOp -> 73 | ( model, Cmd.none ) 74 | 75 | 76 | userDefinedDatePickerSettings : Zone -> Posix -> Settings 77 | userDefinedDatePickerSettings zone today = 78 | let 79 | defaults = 80 | defaultSettings zone 81 | in 82 | { defaults 83 | | isDayDisabled = \clientZone datetime -> isDateBeforeToday (TimeExtra.floor Day clientZone today) datetime 84 | , focusedDate = Just today 85 | , dateStringFn = posixToDateString 86 | , timePickerVisibility = 87 | Toggleable 88 | { defaultTimePickerSettings 89 | | timeStringFn = posixToTimeString 90 | , allowedTimesOfDay = \clientZone datetime -> adjustAllowedTimesOfDayToClientZone Time.utc clientZone today datetime 91 | } 92 | , showCalendarWeekNumbers = True 93 | , presets = [ Settings.PresetDate { title = "Preset", date = today } ] 94 | } 95 | 96 | 97 | view : Model -> Html Msg 98 | view model = 99 | div 100 | [ style "width" "100%" 101 | , style "height" "100vh" 102 | , style "padding" "3rem" 103 | , style "position" "relative" 104 | ] 105 | [ h1 [ style "margin-bottom" "1rem" ] [ text "Modal Example" ] 106 | , div [] 107 | [ div [ style "margin-bottom" "1rem" ] 108 | [ text "This is a basic picker in a scrollable modal" ] 109 | , div [ style "margin-bottom" "1rem" ] 110 | [ button [ onClick <| ToggleModal ] 111 | [ text "Open Modal" ] 112 | ] 113 | ] 114 | , if model.modalOpen then 115 | viewModal model 116 | 117 | else 118 | text "" 119 | ] 120 | 121 | 122 | viewModal : Model -> Html Msg 123 | viewModal model = 124 | div 125 | [ style "position" "fixed" 126 | , style "left" "0" 127 | , style "top" "0" 128 | , style "width" "100%" 129 | , style "height" "100%" 130 | , style "background-color" "rgba(0,0,0,0.25)" 131 | , style "display" "flex" 132 | , style "justify-content" "center" 133 | , style "align-items" "center" 134 | ] 135 | [ div 136 | [ style "position" "absolute" 137 | , style "left" "0" 138 | , style "top" "0" 139 | , style "width" "100%" 140 | , style "height" "100%" 141 | , onClick <| ToggleModal 142 | ] 143 | [] 144 | , div 145 | [ style "width" "300px" 146 | , style "height" "auto" 147 | , style "max-height" "300px" 148 | , style "background-color" "white" 149 | , style "overflow" "auto" 150 | , style "border-radius" "5px" 151 | , style "position" "relative" 152 | , Html.Events.on "scroll" (Decode.succeed OnViewportChange) 153 | ] 154 | [ div [ style "padding" "3rem" ] 155 | [ SingleDatePicker.viewDateInput [] 156 | (userDefinedDatePickerSettings model.zone model.currentTime) 157 | model.currentTime 158 | model.pickedTime 159 | model.picker 160 | , div [ style "margin-bottom" "1rem" ] 161 | [ case model.pickedTime of 162 | Just date -> 163 | text (posixToDateString model.zone date ++ " " ++ posixToTimeString model.zone date) 164 | 165 | Nothing -> 166 | text "No date selected yet!" 167 | ] 168 | , div [ style "margin-bottom" "1rem" ] 169 | [ div [] [ text "This is just some text indicating overflow:" ] 170 | , div [] [ text "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet." ] 171 | ] 172 | ] 173 | ] 174 | ] 175 | 176 | 177 | init : ( Model, Cmd Msg ) 178 | init = 179 | ( { currentTime = Time.millisToPosix 0 180 | , zone = Time.utc 181 | , pickedTime = Nothing 182 | , picker = SingleDatePicker.init UpdatePicker 183 | , modalOpen = False 184 | } 185 | , Task.perform AdjustTimeZone Time.here 186 | ) 187 | 188 | 189 | subscriptions : Model -> Sub Msg 190 | subscriptions model = 191 | Sub.batch 192 | [ SingleDatePicker.subscriptions (userDefinedDatePickerSettings model.zone model.currentTime) model.picker 193 | , Time.every 1000 Tick 194 | , Browser.Events.onResize (\_ _ -> OnViewportChange) 195 | ] 196 | -------------------------------------------------------------------------------- /examples/src/SingleDatePickerExample.elm: -------------------------------------------------------------------------------- 1 | module SingleDatePickerExample exposing (Model, Msg, init, subscriptions, update, view) 2 | 3 | import Css 4 | import DatePicker.DateInput as DateInput 5 | import DatePicker.Settings as Settings 6 | exposing 7 | ( Settings 8 | , TimePickerVisibility(..) 9 | , defaultSettings 10 | , defaultTimePickerSettings 11 | ) 12 | import Html exposing (Html, button, div, h1, text) 13 | import Html.Attributes exposing (class, id, style) 14 | import Html.Events exposing (onClick) 15 | import SingleDatePicker 16 | import Task 17 | import Time exposing (Month(..), Posix, Zone) 18 | import Time.Extra as TimeExtra exposing (Interval(..)) 19 | import Utilities exposing (adjustAllowedTimesOfDayToClientZone, isDateBeforeToday, posixToDateString, posixToTimeString) 20 | 21 | 22 | type Msg 23 | = OpenDetachedPicker String 24 | | UpdateDateInputPicker SingleDatePicker.Msg 25 | | UpdateDetachedPicker SingleDatePicker.Msg 26 | | AdjustTimeZone Zone 27 | | Tick Posix 28 | 29 | 30 | type alias Model = 31 | { currentTime : Posix 32 | , zone : Zone 33 | , dateInputPickerTime : Maybe Posix 34 | , dateInputPicker : SingleDatePicker.DatePicker Msg 35 | , detachedPickerTime : Maybe Posix 36 | , detachedPicker : SingleDatePicker.DatePicker Msg 37 | } 38 | 39 | 40 | update : Msg -> Model -> ( Model, Cmd Msg ) 41 | update msg model = 42 | case msg of 43 | OpenDetachedPicker elementId -> 44 | let 45 | ( newPicker, cmd ) = 46 | SingleDatePicker.openPicker elementId 47 | (userDefinedDatePickerSettings model.zone model.currentTime) 48 | model.currentTime 49 | model.detachedPickerTime 50 | model.detachedPicker 51 | in 52 | ( { model | detachedPicker = newPicker }, cmd ) 53 | 54 | UpdateDetachedPicker subMsg -> 55 | let 56 | ( ( newPicker, maybeNewTime ), cmd ) = 57 | SingleDatePicker.update (userDefinedDatePickerSettings model.zone model.currentTime) subMsg model.detachedPicker 58 | in 59 | ( { model | detachedPicker = newPicker, detachedPickerTime = maybeNewTime }, cmd ) 60 | 61 | UpdateDateInputPicker subMsg -> 62 | let 63 | ( ( newPicker, maybeNewTime ), cmd ) = 64 | SingleDatePicker.update (userDefinedDatePickerSettings model.zone model.currentTime) subMsg model.dateInputPicker 65 | in 66 | ( { model | dateInputPicker = newPicker, dateInputPickerTime = maybeNewTime }, cmd ) 67 | 68 | AdjustTimeZone newZone -> 69 | ( { model | zone = newZone }, Cmd.none ) 70 | 71 | Tick newTime -> 72 | ( { model | currentTime = newTime }, Cmd.none ) 73 | 74 | 75 | userDefinedDatePickerSettings : Zone -> Posix -> Settings 76 | userDefinedDatePickerSettings zone today = 77 | let 78 | defaults = 79 | defaultSettings zone 80 | 81 | allowedTimesOfDay = 82 | \clientZone datetime -> adjustAllowedTimesOfDayToClientZone Time.utc clientZone today datetime 83 | 84 | dateFormat = 85 | DateInput.defaultDateFormat 86 | 87 | timeFormat = 88 | DateInput.defaultTimeFormat 89 | 90 | dateInputSettings = 91 | { format = DateInput.DateTime dateFormat { timeFormat | allowedTimesOfDay = allowedTimesOfDay }, getErrorMessage = getErrorMessage } 92 | in 93 | { defaults 94 | | isDayDisabled = \clientZone datetime -> isDateBeforeToday (TimeExtra.floor Day clientZone today) datetime 95 | , focusedDate = Just today 96 | , dateStringFn = posixToDateString 97 | , timePickerVisibility = 98 | Toggleable 99 | { defaultTimePickerSettings 100 | | timeStringFn = posixToTimeString 101 | , allowedTimesOfDay = \clientZone datetime -> adjustAllowedTimesOfDayToClientZone Time.utc clientZone today datetime 102 | } 103 | , showCalendarWeekNumbers = True 104 | , presets = [ Settings.PresetDate { title = "Preset", date = today } ] 105 | 106 | -- , presets = [] 107 | , dateInputSettings = dateInputSettings 108 | } 109 | 110 | 111 | getErrorMessage : DateInput.InputError -> String 112 | getErrorMessage error = 113 | case error of 114 | DateInput.ValueInvalid -> 115 | "Invalid value. Make sure to use the correct format." 116 | 117 | DateInput.ValueNotAllowed -> 118 | "Date not allowed." 119 | 120 | _ -> 121 | "" 122 | 123 | 124 | view : Model -> Html Msg 125 | view model = 126 | div 127 | [ style "width" "100%" 128 | , style "height" "100vh" 129 | , style "padding" "3rem" 130 | ] 131 | [ h1 [ style "margin-bottom" "1rem" ] [ text "SingleDatePicker Example" ] 132 | , div [] 133 | [ div [ style "margin-bottom" "1rem" ] 134 | [ text "This is the picker rendered with a date input" ] 135 | , div [ style "position" "relative", style "margin-bottom" "1rem", style "width" "250px" ] 136 | [ SingleDatePicker.viewDateInput [] 137 | (userDefinedDatePickerSettings model.zone model.currentTime) 138 | model.currentTime 139 | model.dateInputPickerTime 140 | model.dateInputPicker 141 | ] 142 | , div [ style "margin-bottom" "1rem" ] 143 | [ text "This is the detached picker rendered on click of a button" ] 144 | , div [ style "position" "relative", style "margin-bottom" "1rem", style "display" "flex", style "gap" "1rem", style "align-items" "center" ] 145 | [ button [ id "my-button", onClick (OpenDetachedPicker "my-button") ] [ text "Open the picker here" ] 146 | , SingleDatePicker.view 147 | (userDefinedDatePickerSettings model.zone model.currentTime) 148 | model.detachedPicker 149 | , div [] 150 | [ text "Picked time: " 151 | , case model.detachedPickerTime of 152 | Just t -> 153 | text (Utilities.posixToDateString model.zone t ++ " " ++ Utilities.posixToTimeString model.zone t) 154 | 155 | Nothing -> 156 | text "No date selected yet!" 157 | ] 158 | ] 159 | ] 160 | ] 161 | 162 | 163 | init : ( Model, Cmd Msg ) 164 | init = 165 | ( { currentTime = Time.millisToPosix 0 166 | , zone = Time.utc 167 | , dateInputPickerTime = Nothing 168 | , dateInputPicker = SingleDatePicker.init UpdateDateInputPicker 169 | , detachedPickerTime = Nothing 170 | , detachedPicker = SingleDatePicker.init UpdateDetachedPicker 171 | } 172 | , Task.perform AdjustTimeZone Time.here 173 | ) 174 | 175 | 176 | subscriptions : Model -> Sub Msg 177 | subscriptions model = 178 | Sub.batch 179 | [ SingleDatePicker.subscriptions (userDefinedDatePickerSettings model.zone model.currentTime) model.dateInputPicker 180 | , SingleDatePicker.subscriptions (userDefinedDatePickerSettings model.zone model.currentTime) model.detachedPicker 181 | , Time.every 1000 Tick 182 | ] 183 | -------------------------------------------------------------------------------- /examples/src/Utilities.elm: -------------------------------------------------------------------------------- 1 | module Utilities exposing (adjustAllowedTimesOfDayToClientZone, isDateBeforeToday, posixToDateString, posixToTimeString) 2 | 3 | import Time exposing (Month(..), Posix, Zone) 4 | import Time.Extra as TimeExtra exposing (Interval(..)) 5 | 6 | 7 | addLeadingZero : Int -> String 8 | addLeadingZero value = 9 | let 10 | string = 11 | String.fromInt value 12 | in 13 | if String.length string == 1 then 14 | "0" ++ string 15 | 16 | else 17 | string 18 | 19 | 20 | monthToNmbString : Month -> String 21 | monthToNmbString month = 22 | case month of 23 | Jan -> 24 | "01" 25 | 26 | Feb -> 27 | "02" 28 | 29 | Mar -> 30 | "03" 31 | 32 | Apr -> 33 | "04" 34 | 35 | May -> 36 | "05" 37 | 38 | Jun -> 39 | "06" 40 | 41 | Jul -> 42 | "07" 43 | 44 | Aug -> 45 | "08" 46 | 47 | Sep -> 48 | "09" 49 | 50 | Oct -> 51 | "10" 52 | 53 | Nov -> 54 | "11" 55 | 56 | Dec -> 57 | "12" 58 | 59 | 60 | isDateBeforeToday : Posix -> Posix -> Bool 61 | isDateBeforeToday today datetime = 62 | Time.posixToMillis today > Time.posixToMillis datetime 63 | 64 | 65 | posixToDateString : Zone -> Posix -> String 66 | posixToDateString zone date = 67 | addLeadingZero (Time.toDay zone date) 68 | ++ "." 69 | ++ monthToNmbString (Time.toMonth zone date) 70 | ++ "." 71 | ++ addLeadingZero (Time.toYear zone date) 72 | 73 | 74 | posixToTimeString : Zone -> Posix -> String 75 | posixToTimeString zone datetime = 76 | addLeadingZero (Time.toHour zone datetime) 77 | ++ ":" 78 | ++ addLeadingZero (Time.toMinute zone datetime) 79 | ++ ":" 80 | ++ addLeadingZero (Time.toSecond zone datetime) 81 | 82 | 83 | {-| The goal of this naive function is to adjust 84 | the allowed time boundaries within the baseZone 85 | to the time zone in which the picker is running 86 | (clientZone) for the current day being processed 87 | (datetime). 88 | 89 | For example, the allowed times of day could be 90 | 9am - 5pm EST. However, if someone is using the 91 | picker in MST (2 hours behind EST), the allowed 92 | times of day displayed in the picker should be 93 | 7am - 3pm. 94 | 95 | There is likely a better way to do this, but it 96 | is suitable as an example. 97 | 98 | -} 99 | adjustAllowedTimesOfDayToClientZone : Zone -> Zone -> Posix -> Posix -> { startHour : Int, startMinute : Int, endHour : Int, endMinute : Int } 100 | adjustAllowedTimesOfDayToClientZone baseZone clientZone today datetimeBeingProcessed = 101 | let 102 | processingPartsInClientZone = 103 | TimeExtra.posixToParts clientZone datetimeBeingProcessed 104 | 105 | todayPartsInClientZone = 106 | TimeExtra.posixToParts clientZone today 107 | 108 | startPartsAdjustedForBaseZone = 109 | TimeExtra.posixToParts baseZone datetimeBeingProcessed 110 | |> (\parts -> TimeExtra.partsToPosix baseZone { parts | hour = 8, minute = 0 }) 111 | |> TimeExtra.posixToParts clientZone 112 | 113 | endPartsAdjustedForBaseZone = 114 | TimeExtra.posixToParts baseZone datetimeBeingProcessed 115 | |> (\parts -> TimeExtra.partsToPosix baseZone { parts | hour = 17, minute = 30 }) 116 | |> TimeExtra.posixToParts clientZone 117 | 118 | bounds = 119 | { startHour = startPartsAdjustedForBaseZone.hour 120 | , startMinute = startPartsAdjustedForBaseZone.minute 121 | , endHour = endPartsAdjustedForBaseZone.hour 122 | , endMinute = endPartsAdjustedForBaseZone.minute 123 | } 124 | in 125 | if processingPartsInClientZone.day == todayPartsInClientZone.day && processingPartsInClientZone.month == todayPartsInClientZone.month && processingPartsInClientZone.year == todayPartsInClientZone.year then 126 | if todayPartsInClientZone.hour > bounds.startHour || (todayPartsInClientZone.hour == bounds.startHour && todayPartsInClientZone.minute > bounds.startMinute) then 127 | { startHour = todayPartsInClientZone.hour, startMinute = todayPartsInClientZone.minute, endHour = bounds.endHour, endMinute = bounds.endMinute } 128 | 129 | else 130 | bounds 131 | 132 | else 133 | bounds 134 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "repository": {}, 3 | "license": "MIT", 4 | "scripts": { 5 | "test": "elm-test", 6 | "start": "parcel serve --no-cache examples/index.html" 7 | }, 8 | "devDependencies": { 9 | "@parcel/transformer-elm": "^2.12.0", 10 | "elm-test": "^0.19.1-revision12", 11 | "elm": "^0.19.1-6", 12 | "elm-format": "^0.8.7", 13 | "parcel": "^2.12.0" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/DatePicker/Alignment.elm: -------------------------------------------------------------------------------- 1 | module DatePicker.Alignment exposing 2 | ( Alignment 3 | , init, update 4 | , dateInputStylesFromAlignment, calcDateInputWidth, calcDurationDateInputWidth 5 | , pickerStylesFromAlignment, applyPickerStyles, pickerPositionFromAlignment, pickerTranslationFromAlignment 6 | , gridAreaPresets, gridAreaDateInput, gridAreaCalendar 7 | , pickerGridLayoutFromAlignment 8 | , PlacementX(..), PlacementY(..), calculatePlacement 9 | ) 10 | 11 | {-| This module provides utilities for determining the alignment and layout 12 | of a date picker component, ensuring proper positioning relative to 13 | its trigger element and viewport constraints. 14 | 15 | @docs Alignment 16 | @docs init, update 17 | @docs dateInputStylesFromAlignment, calcDateInputWidth, calcDurationDateInputWidth 18 | @docs pickerStylesFromAlignment, applyPickerStyles, pickerPositionFromAlignment, pickerTranslationFromAlignment 19 | @docs gridAreaPresets, gridAreaDateInput, gridAreaCalendar 20 | @docs pickerGridLayoutFromAlignment 21 | 22 | 23 | # Test 24 | 25 | @docs PlacementX, PlacementY, calculatePlacement 26 | 27 | -} 28 | 29 | import Browser.Dom as Dom 30 | import Css 31 | import DatePicker.Theme as Theme exposing (Theme) 32 | import Task exposing (Task) 33 | import Task.Extra as TaskExtra 34 | 35 | 36 | {-| Represents the alignment of a date picker relative to a trigger element. 37 | It includes information about the placement and the elements involved. 38 | -} 39 | type Alignment 40 | = Alignment 41 | { placement : ( PlacementX, PlacementY ) 42 | , trigger : Element 43 | , picker : Element 44 | } 45 | 46 | 47 | {-| Represents an HTML element with position and size details. 48 | -} 49 | type alias Element = 50 | { id : String, x : Float, y : Float, width : Float, height : Float } 51 | 52 | 53 | {-| Horizontal placement options for the date picker. 54 | -} 55 | type PlacementX 56 | = Left 57 | | Right 58 | | Center 59 | 60 | 61 | {-| Vertical placement options for the date picker. 62 | -} 63 | type PlacementY 64 | = Top 65 | | Bottom 66 | 67 | 68 | {-| Creates the Alignment instance of a date picker popover based on the position of the trigger element, 69 | the picker itself, and the viewport constraints. 70 | -} 71 | fromElements : 72 | { trigger : Element 73 | , picker : Element 74 | , viewport : Element 75 | } 76 | -> Alignment 77 | fromElements { trigger, picker, viewport } = 78 | let 79 | placement = 80 | calculatePlacement 81 | { triggerX = trigger.x 82 | , triggerY = trigger.y 83 | , triggerWidth = trigger.width 84 | , triggerHeight = trigger.height 85 | , pickerWidth = picker.width 86 | , pickerHeight = picker.height 87 | , viewPortWidth = viewport.width 88 | , viewPortHeight = viewport.height 89 | } 90 | in 91 | Alignment 92 | { placement = placement 93 | , trigger = trigger 94 | , picker = picker 95 | } 96 | 97 | 98 | {-| Type facilitating the parameters for the `calculatePlacement` function. 99 | -} 100 | type alias CalculatePlacementParams = 101 | { triggerX : Float 102 | , triggerY : Float 103 | , triggerWidth : Float 104 | , triggerHeight : Float 105 | , pickerWidth : Float 106 | , pickerHeight : Float 107 | , viewPortWidth : Float 108 | , viewPortHeight : Float 109 | } 110 | 111 | 112 | {-| Calculates the horizontal and vertical placements for the picker popover relative to the trigger element. 113 | This is based on the position of the trigger element, the size of the picker, and the viewport constraints. 114 | -} 115 | calculatePlacement : CalculatePlacementParams -> ( PlacementX, PlacementY ) 116 | calculatePlacement { triggerX, triggerY, triggerWidth, triggerHeight, pickerWidth, pickerHeight, viewPortWidth, viewPortHeight } = 117 | let 118 | minOffset = 119 | 10 120 | 121 | triggerLeft = 122 | triggerX 123 | 124 | triggerRight = 125 | triggerX + triggerWidth 126 | 127 | triggerCenter = 128 | triggerX + triggerWidth / 2 129 | 130 | triggerBottom = 131 | triggerY + triggerHeight 132 | 133 | placementX = 134 | if (triggerLeft + pickerWidth) <= (viewPortWidth - minOffset) then 135 | Left 136 | 137 | else if (triggerRight - pickerWidth) >= minOffset then 138 | Right 139 | 140 | else if 141 | (triggerCenter - pickerWidth / 2) 142 | >= minOffset 143 | && (triggerCenter + pickerWidth / 2) 144 | <= (viewPortWidth - minOffset) 145 | then 146 | Center 147 | 148 | else 149 | Left 150 | 151 | placementY = 152 | if (triggerBottom + pickerHeight) > viewPortHeight then 153 | Top 154 | 155 | else 156 | Bottom 157 | in 158 | ( placementX, placementY ) 159 | 160 | 161 | {-| Initializes the alignment by fetching the positions of the trigger and picker elements 162 | from the DOM. Calls `handleResponse` with the result. 163 | -} 164 | init : { triggerId : String, pickerId : String } -> (Result Dom.Error Alignment -> msg) -> Cmd msg 165 | init elementIds handleResponse = 166 | Task.attempt handleResponse 167 | (getElements elementIds) 168 | 169 | 170 | {-| Updates the alignment by re-fetching the positions of the elements and recalculating alignment. 171 | -} 172 | update : (Result Dom.Error Alignment -> msg) -> Alignment -> Cmd msg 173 | update handleResponse (Alignment { trigger, picker }) = 174 | init { pickerId = picker.id, triggerId = trigger.id } handleResponse 175 | 176 | 177 | {-| Retrieves the DOM elements by their IDs and constructs an `Alignment` instance. 178 | -} 179 | getElements : { triggerId : String, pickerId : String } -> Task Dom.Error Alignment 180 | getElements { triggerId, pickerId } = 181 | let 182 | elementFromDomElement : String -> { x : Float, y : Float, width : Float, height : Float } -> Element 183 | elementFromDomElement id domElement = 184 | { id = id 185 | , x = domElement.x 186 | , y = domElement.y 187 | , width = domElement.width 188 | , height = domElement.height 189 | } 190 | in 191 | Task.succeed 192 | (\picker trigger viewport -> 193 | fromElements 194 | { trigger = elementFromDomElement triggerId trigger.element 195 | , picker = elementFromDomElement pickerId picker.element 196 | , viewport = elementFromDomElement "" viewport.viewport 197 | } 198 | ) 199 | |> TaskExtra.andMap (Dom.getElement pickerId) 200 | |> TaskExtra.andMap (Dom.getElement triggerId) 201 | |> TaskExtra.andMap Dom.getViewport 202 | 203 | 204 | {-| Computes the styles for the date input view based on the alignment and visibility of the picker popover. 205 | When the picker is opened, the date input view is positioned fixed on top of the picker popover, manually 206 | integrating itself into the popover's layout. 207 | When the picker is closed, the date input view is positioned absolute to it's container element. 208 | 209 | The date input's width needs to be passed in order to scale it correctly into the picker's layout (when the 210 | picker is opened, the date input should be the same width as the calendar). 211 | 212 | -} 213 | dateInputStylesFromAlignment : Theme.Theme -> Bool -> Float -> Maybe Alignment -> List Css.Style 214 | dateInputStylesFromAlignment theme isPickerOpen width maybeAlignment = 215 | let 216 | closedStyles = 217 | [ Css.position Css.absolute 218 | , Css.top (Css.px 0) 219 | , Css.left (Css.px 0) 220 | , Css.zIndex (Css.int (theme.zIndex + 10)) 221 | , Css.width (Css.pct 100) 222 | ] 223 | in 224 | case ( maybeAlignment, isPickerOpen ) of 225 | ( Just alignment, True ) -> 226 | let 227 | { x, y } = 228 | fixedDateInputCoorinatesFromAlignment width alignment 229 | in 230 | [ Css.position Css.fixed 231 | , Css.zIndex (Css.int (theme.zIndex + 10)) 232 | , Css.left (Css.px x) 233 | , Css.top (Css.px y) 234 | , Css.width (Css.px width) 235 | ] 236 | 237 | ( _, _ ) -> 238 | closedStyles 239 | 240 | 241 | {-| Calculates the width of a single date input view element. 242 | When the picker is opened, the date input should be the same width as the calendar 243 | -} 244 | calcDateInputWidth : Theme.Theme -> Bool -> Float 245 | calcDateInputWidth theme showCalendarWeekNumbers = 246 | calendarWidth theme showCalendarWeekNumbers 247 | 248 | 249 | {-| Calculates the width of two date input elements for duration picking. 250 | When the picker is opened, each date input should be the same width as one calendar month. 251 | So the total width is twice the width of one calendar month (plus spacing in between). 252 | -} 253 | calcDurationDateInputWidth : Theme.Theme -> Bool -> Float 254 | calcDurationDateInputWidth theme showCalendarWeekNumbers = 255 | 2 * calendarWidth theme showCalendarWeekNumbers + 2 * theme.spacing.base 256 | 257 | 258 | {-| Computes the calendar width based on whether the week numbers are shown. 259 | -} 260 | calendarWidth : Theme -> Bool -> Float 261 | calendarWidth theme showCalendarWeekNumbers = 262 | let 263 | factor = 264 | if showCalendarWeekNumbers then 265 | 8 266 | 267 | else 268 | 7 269 | in 270 | factor * theme.size.day 271 | 272 | 273 | {-| Computes the fixed coordinates for the date input field based on alignment. 274 | -} 275 | fixedDateInputCoorinatesFromAlignment : Float -> Alignment -> { x : Float, y : Float } 276 | fixedDateInputCoorinatesFromAlignment dateInputWidth (Alignment { placement, trigger, picker }) = 277 | let 278 | ( placementX, _ ) = 279 | placement 280 | 281 | x = 282 | case placementX of 283 | Left -> 284 | trigger.x 285 | 286 | Center -> 287 | trigger.x + trigger.width / 2 - picker.width / 2 288 | 289 | Right -> 290 | trigger.x + trigger.width - dateInputWidth 291 | 292 | y = 293 | trigger.y 294 | in 295 | { x = x, y = y } 296 | 297 | 298 | {-| Computes the styles for positioning the picker based on the given alignment. 299 | -} 300 | pickerStylesFromAlignment : Theme -> Maybe Alignment -> List Css.Style 301 | pickerStylesFromAlignment theme maybeAlignment = 302 | case maybeAlignment of 303 | Just alignment -> 304 | let 305 | { x, y } = 306 | fixedPickerCoorinatesFromAlignment alignment 307 | in 308 | [ Css.position Css.fixed 309 | , Css.zIndex (Css.int theme.zIndex) 310 | , Css.left (Css.px x) 311 | , Css.top (Css.px y) 312 | ] 313 | 314 | _ -> 315 | -- hide picker element until the DOM elements have been found 316 | [ Css.visibility Css.hidden ] 317 | 318 | 319 | {-| Applies the provided styling function to an `Alignment` if available. 320 | Otherwise, hides the picker until alignment is determined. 321 | -} 322 | applyPickerStyles : (Alignment -> List Css.Style) -> Maybe Alignment -> List Css.Style 323 | applyPickerStyles stylingFn maybeAlignment = 324 | case maybeAlignment of 325 | Just alignment -> 326 | stylingFn alignment 327 | 328 | Nothing -> 329 | -- hide picker element until the DOM elements have been found 330 | [ Css.visibility Css.hidden 331 | , defaultGridLayout 332 | ] 333 | 334 | 335 | {-| Determines the CSS fixed position styles for the picker popover based on alignment. 336 | -} 337 | pickerPositionFromAlignment : Theme -> Alignment -> Css.Style 338 | pickerPositionFromAlignment theme alignment = 339 | let 340 | { x, y } = 341 | fixedPickerCoorinatesFromAlignment alignment 342 | in 343 | Css.batch 344 | [ Css.position Css.fixed 345 | , Css.zIndex (Css.int theme.zIndex) 346 | , Css.left (Css.px x) 347 | , Css.top (Css.px y) 348 | ] 349 | 350 | 351 | {-| Computes the translation transformation for the picker container, 352 | adjusting its position based on alignment to frame the date input element. 353 | -} 354 | pickerTranslationFromAlignment : Theme -> Alignment -> Css.Style 355 | pickerTranslationFromAlignment theme (Alignment { placement }) = 356 | let 357 | ( placementX, placementY ) = 358 | placement 359 | 360 | framePadding = 361 | theme.spacing.base 362 | 363 | inputHeight = 364 | theme.size.inputElement 365 | 366 | translate : { x : Float, y : Float } -> Css.Style 367 | translate { x, y } = 368 | Css.property "transform" ("translateX(" ++ String.fromFloat x ++ "px) translateY(" ++ String.fromFloat y ++ "px)") 369 | in 370 | -- container translation to frame date input 371 | case ( placementX, placementY ) of 372 | ( Left, Bottom ) -> 373 | translate { x = -framePadding, y = -framePadding - inputHeight } 374 | 375 | ( Left, Top ) -> 376 | translate { x = -framePadding, y = framePadding + inputHeight } 377 | 378 | ( Right, Bottom ) -> 379 | translate { x = framePadding, y = -framePadding - inputHeight } 380 | 381 | ( Right, Top ) -> 382 | translate { x = framePadding, y = 2 * framePadding + inputHeight } 383 | 384 | ( Center, Bottom ) -> 385 | translate { x = -framePadding, y = -framePadding - inputHeight } 386 | 387 | ( Center, Top ) -> 388 | translate { x = -framePadding, y = 2 * framePadding + inputHeight } 389 | 390 | 391 | {-| Computes the fixed coordinates for the picker based on its alignment 392 | relative to the trigger element. 393 | -} 394 | fixedPickerCoorinatesFromAlignment : Alignment -> { x : Float, y : Float } 395 | fixedPickerCoorinatesFromAlignment (Alignment { placement, trigger, picker }) = 396 | let 397 | ( placementX, placementY ) = 398 | placement 399 | 400 | x = 401 | case placementX of 402 | Left -> 403 | trigger.x 404 | 405 | Center -> 406 | trigger.x + trigger.width / 2 - picker.width / 2 407 | 408 | Right -> 409 | trigger.x + trigger.width - picker.width 410 | 411 | y = 412 | case placementY of 413 | Top -> 414 | trigger.y - picker.height 415 | 416 | Bottom -> 417 | trigger.y + trigger.height 418 | in 419 | { x = x, y = y } 420 | 421 | 422 | {-| CSS grid area name for the preset buttons section. 423 | -} 424 | gridAreaPresets : String 425 | gridAreaPresets = 426 | "presets" 427 | 428 | 429 | {-| CSS grid area name for the date input field. 430 | -} 431 | gridAreaDateInput : String 432 | gridAreaDateInput = 433 | "input" 434 | 435 | 436 | {-| CSS grid area name for the calendar section. 437 | -} 438 | gridAreaCalendar : String 439 | gridAreaCalendar = 440 | "calendar" 441 | 442 | 443 | {-| Generates a `grid-template` CSS style from a list of row definitions. 444 | Each row is represented as a list of strings corresponding to grid areas. 445 | -} 446 | gridTemplateFromList : List (List String) -> Css.Style 447 | gridTemplateFromList listTemplate = 448 | let 449 | template = 450 | String.join " " 451 | (List.map 452 | (\row -> "'" ++ String.join " " row ++ "'") 453 | listTemplate 454 | ) 455 | in 456 | Css.property "grid-template" template 457 | 458 | 459 | {-| Computes the grid layout for the picker based on its alignment. 460 | Adjusts the order of the date input, calendar, and preset sections 461 | based on whether the picker appears above or below, left or right. 462 | -} 463 | pickerGridLayoutFromAlignment : Alignment -> Css.Style 464 | pickerGridLayoutFromAlignment (Alignment { placement }) = 465 | let 466 | template = 467 | case ( Tuple.first placement, Tuple.second placement ) of 468 | ( Left, Bottom ) -> 469 | [ [ gridAreaDateInput, gridAreaPresets ] 470 | , [ gridAreaCalendar, gridAreaPresets ] 471 | ] 472 | 473 | ( Left, Top ) -> 474 | [ [ gridAreaCalendar, gridAreaPresets ] 475 | , [ gridAreaDateInput, gridAreaPresets ] 476 | ] 477 | 478 | ( Right, Bottom ) -> 479 | [ [ gridAreaPresets, gridAreaDateInput ] 480 | , [ gridAreaPresets, gridAreaCalendar ] 481 | ] 482 | 483 | ( Right, Top ) -> 484 | [ [ gridAreaPresets, gridAreaCalendar ] 485 | , [ gridAreaPresets, gridAreaDateInput ] 486 | ] 487 | 488 | ( Center, Bottom ) -> 489 | [ [ gridAreaDateInput, gridAreaPresets ] 490 | , [ gridAreaCalendar, gridAreaPresets ] 491 | ] 492 | 493 | ( Center, Top ) -> 494 | [ [ gridAreaCalendar, gridAreaPresets ] 495 | , [ gridAreaDateInput, gridAreaPresets ] 496 | ] 497 | in 498 | Css.batch 499 | [ Css.property "display" "grid" 500 | , gridTemplateFromList template 501 | ] 502 | 503 | 504 | {-| Default grid layout used when alignment is not yet determined. 505 | -} 506 | defaultGridLayout : Css.Style 507 | defaultGridLayout = 508 | let 509 | template = 510 | [ [ gridAreaDateInput, gridAreaPresets ] 511 | , [ gridAreaCalendar, gridAreaPresets ] 512 | ] 513 | in 514 | Css.batch 515 | [ Css.property "display" "grid" 516 | , gridTemplateFromList template 517 | ] 518 | -------------------------------------------------------------------------------- /src/DatePicker/Icons.elm: -------------------------------------------------------------------------------- 1 | module DatePicker.Icons exposing (Icon(..), arrowRight, calendar, check, chevronDown, chevronLeft, chevronRight, chevronUp, chevronsLeft, chevronsRight, clock, edit, toHtml, withSize) 2 | 3 | import Html exposing (Html) 4 | import Svg exposing (Svg, svg) 5 | import Svg.Attributes exposing (..) 6 | 7 | 8 | 9 | -- The code below comes from feathericons/elm-feather (https://github.com/feathericons/elm-feather/) 10 | 11 | 12 | {-| Customizable attributes of icon 13 | -} 14 | type alias IconAttributes = 15 | { size : Float 16 | , sizeUnit : String 17 | , strokeWidth : Float 18 | , class : Maybe String 19 | , viewBox : String 20 | } 21 | 22 | 23 | {-| Default attributes, first argument is icon name 24 | -} 25 | defaultAttributes : String -> IconAttributes 26 | defaultAttributes name = 27 | { size = 24 28 | , sizeUnit = "" 29 | , strokeWidth = 2 30 | , class = Just <| "feather feather-" ++ name 31 | , viewBox = "0 0 24 24" 32 | } 33 | 34 | 35 | {-| Opaque type representing icon builder 36 | -} 37 | type Icon 38 | = Icon 39 | { attrs : IconAttributes 40 | , src : List (Svg Never) 41 | } 42 | 43 | 44 | {-| Set size attribute of an icon 45 | Icon.download 46 | |> Icon.withSize 10 47 | |> Icon.toHtml [] 48 | -} 49 | withSize : Float -> Icon -> Icon 50 | withSize size (Icon { attrs, src }) = 51 | Icon { attrs = { attrs | size = size }, src = src } 52 | 53 | 54 | {-| Build icon, ready to use in html. It accepts list of svg attributes, for example in case if you want to add an event handler. 55 | -- default 56 | Icon.download 57 | |> Icon.toHtml [] 58 | -- with some attributes 59 | Icon.download 60 | |> Icon.withSize 10 61 | |> Icon.withClass "icon-download" 62 | |> Icon.toHtml [ onClick Download ] 63 | -} 64 | toHtml : List (Svg.Attribute msg) -> Icon -> Html msg 65 | toHtml attributes (Icon { src, attrs }) = 66 | let 67 | strSize = 68 | attrs.size |> String.fromFloat 69 | 70 | baseAttributes = 71 | [ fill "none" 72 | , height <| strSize ++ attrs.sizeUnit 73 | , width <| strSize ++ attrs.sizeUnit 74 | , stroke "currentColor" 75 | , strokeLinecap "round" 76 | , strokeLinejoin "round" 77 | , strokeWidth <| String.fromFloat attrs.strokeWidth 78 | , viewBox attrs.viewBox 79 | ] 80 | 81 | combinedAttributes = 82 | (case attrs.class of 83 | Just c -> 84 | class c :: baseAttributes 85 | 86 | Nothing -> 87 | baseAttributes 88 | ) 89 | ++ attributes 90 | in 91 | src 92 | |> List.map (Svg.map never) 93 | |> svg combinedAttributes 94 | 95 | 96 | makeBuilder : String -> List (Svg Never) -> Icon 97 | makeBuilder name src = 98 | Icon { attrs = defaultAttributes name, src = src } 99 | 100 | 101 | {-| check 102 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjIwIDYgOSAxNyA0IDEyIj48L3BvbHlsaW5lPjwvc3ZnPg==) 103 | -} 104 | check : Icon 105 | check = 106 | makeBuilder "check" 107 | [ Svg.polyline [ points "20 6 9 17 4 12" ] [] 108 | ] 109 | 110 | 111 | {-| chevron-left 112 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjE1IDE4IDkgMTIgMTUgNiI+PC9wb2x5bGluZT48L3N2Zz4=) 113 | -} 114 | chevronLeft : Icon 115 | chevronLeft = 116 | makeBuilder "chevron-left" 117 | [ Svg.polyline [ points "15 18 9 12 15 6" ] [] 118 | ] 119 | 120 | 121 | {-| chevron-right 122 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjkgMTggMTUgMTIgOSA2Ij48L3BvbHlsaW5lPjwvc3ZnPg==) 123 | -} 124 | chevronRight : Icon 125 | chevronRight = 126 | makeBuilder "chevron-right" 127 | [ Svg.polyline [ points "9 18 15 12 9 6" ] [] 128 | ] 129 | 130 | 131 | {-| chevrons-left 132 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjExIDE3IDYgMTIgMTEgNyI+PC9wb2x5bGluZT48cG9seWxpbmUgcG9pbnRzPSIxOCAxNyAxMyAxMiAxOCA3Ij48L3BvbHlsaW5lPjwvc3ZnPg==) 133 | -} 134 | chevronsLeft : Icon 135 | chevronsLeft = 136 | makeBuilder "chevrons-left" 137 | [ Svg.polyline [ points "11 17 6 12 11 7" ] [] 138 | , Svg.polyline [ points "18 17 13 12 18 7" ] [] 139 | ] 140 | 141 | 142 | {-| chevrons-right 143 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjEzIDE3IDE4IDEyIDEzIDciPjwvcG9seWxpbmU+PHBvbHlsaW5lIHBvaW50cz0iNiAxNyAxMSAxMiA2IDciPjwvcG9seWxpbmU+PC9zdmc+) 144 | -} 145 | chevronsRight : Icon 146 | chevronsRight = 147 | makeBuilder "chevrons-right" 148 | [ Svg.polyline [ points "13 17 18 12 13 7" ] [] 149 | , Svg.polyline [ points "6 17 11 12 6 7" ] [] 150 | ] 151 | 152 | 153 | {-| chevron-up 154 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjE4IDE1IDEyIDkgNiAxNSI+PC9wb2x5bGluZT48L3N2Zz4=) 155 | -} 156 | chevronUp : Icon 157 | chevronUp = 158 | makeBuilder "chevron-up" 159 | [ Svg.polyline [ points "18 15 12 9 6 15" ] [] 160 | ] 161 | 162 | 163 | {-| chevron-down 164 | ![image](data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0IiBmaWxsPSJub25lIiBzdHJva2U9ImN1cnJlbnRDb2xvciIgc3Ryb2tlLXdpZHRoPSIyIiBzdHJva2UtbGluZWNhcD0icm91bmQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiPjxwb2x5bGluZSBwb2ludHM9IjYgOSAxMiAxNSAxOCA5Ij48L3BvbHlsaW5lPjwvc3ZnPg==) 165 | -} 166 | chevronDown : Icon 167 | chevronDown = 168 | makeBuilder "chevron-down" 169 | [ Svg.polyline [ points "6 9 12 15 18 9" ] [] 170 | ] 171 | 172 | 173 | {-| clock 174 | -} 175 | clock : Icon 176 | clock = 177 | makeBuilder "clock" 178 | [ Svg.circle [ cx "12", cy "12", r "10" ] [] 179 | , Svg.polyline [ points "12 6 12 12 16 14" ] [] 180 | ] 181 | 182 | 183 | {-| calendar 184 | -} 185 | calendar : Icon 186 | calendar = 187 | makeBuilder "clock" 188 | [ Svg.rect [ x "3", y "4", width "18", height "18", rx "2", ry "2" ] [] 189 | , Svg.line [ x1 "16", y1 "2", x2 "16", y2 "6" ] [] 190 | , Svg.line [ x1 "8", y1 "2", x2 "8", y2 "6" ] [] 191 | , Svg.line [ x1 "3", y1 "10", x2 "21", y2 "10" ] [] 192 | ] 193 | 194 | 195 | {-| edit 196 | -} 197 | edit : Icon 198 | edit = 199 | makeBuilder "edit" 200 | [ Svg.path [ d "M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" ] [] 201 | , Svg.path [ d "M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" ] [] 202 | ] 203 | 204 | 205 | {-| arrow-right 206 | -} 207 | arrowRight : Icon 208 | arrowRight = 209 | makeBuilder "arrow-right" 210 | [ Svg.line [ x1 "5", y1 "12", x2 "19", y2 "12" ] [] 211 | , Svg.polyline [ points "12 5 19 12 12 19" ] [] 212 | ] 213 | -------------------------------------------------------------------------------- /src/DatePicker/Settings.elm: -------------------------------------------------------------------------------- 1 | module DatePicker.Settings exposing 2 | ( Settings, defaultSettings 3 | , TimePickerVisibility(..), TimePickerSettings, defaultTimePickerSettings 4 | , Preset(..), PresetDateConfig, PresetRangeConfig 5 | , generatePickerDay, getTimePickerSettings, isTimePickerVisible 6 | , isPresetDateActive, isPresetRangeActive 7 | ) 8 | 9 | {-| All settings and configuration utilities for both pickers. 10 | 11 | 12 | # Settings 13 | 14 | @docs Settings, defaultSettings 15 | @docs TimePickerVisibility, TimePickerSettings, defaultTimePickerSettings 16 | @docs Preset, PresetDateConfig, PresetRangeConfig 17 | 18 | 19 | # Query 20 | 21 | @docs generatePickerDay, getTimePickerSettings, isTimePickerVisible 22 | @docs isPresetDateActive, isPresetRangeActive 23 | 24 | -} 25 | 26 | import DatePicker.DateInput as DateInput 27 | import DatePicker.Theme as Theme 28 | import DatePicker.Utilities as Utilities exposing (PickerDay) 29 | import Time exposing (Month(..), Posix, Weekday(..), Zone) 30 | import Time.Extra as Time exposing (Interval(..)) 31 | 32 | 33 | {-| The type facilitating the configuration of the datepicker settings. 34 | 35 | `id` - provides a custom id to the picker element 36 | `zone` - the `Zone` in which the date picker is being used (client zone) 37 | `formattedDay` - a function that returns a string representation for the provided day of the week 38 | `formattedMonth` - a function that returns a string representation for the provided month 39 | `isDayDisabled` - a function that determines if the combined `Posix` and `Zone` represent a day that should be disabled in the picker 40 | `focusedDate` - a `Posix` that represents a day that should be highlighted on the picker (i.e. the current day) 41 | `dateStringFn` - a function that returns a string representation of the selected day 42 | `timePickerVisibility` - see below 43 | `showCalendarWeekNumbers` - wheather to display or not display caldendar week numbers 44 | `presets` - a list of `Presets`, for selectable, preconfigured dates 45 | `theme` - a record of customizable design tokens 46 | 47 | More information can be found in the [examples](https://github.com/mercurymedia/elm-datetime-picker/tree/master/examples). 48 | 49 | -} 50 | type alias Settings = 51 | { zone : Zone 52 | , id : String 53 | , firstWeekDay : Weekday 54 | , formattedDay : Weekday -> String 55 | , formattedMonth : Month -> String 56 | , isDayDisabled : Zone -> Posix -> Bool 57 | , focusedDate : Maybe Posix 58 | , dateStringFn : Zone -> Posix -> String 59 | , timePickerVisibility : TimePickerVisibility 60 | , showCalendarWeekNumbers : Bool 61 | , presets : List Preset 62 | , theme : Theme.Theme 63 | , dateInputSettings : DateInput.Settings 64 | } 65 | 66 | 67 | {-| Set the visibility of the timepicker in the `DateTimePicker` 68 | 69 | `NeverVisible` - The time picker is never visible. Please note that 70 | while ostensibly picking a day, a selection still returns a Posix 71 | representing the beginning of that day (00:00). It is up to you to 72 | process the selection accordingly if you wish to treat it as a whole day. 73 | 74 | `Toggleable` - The time picker visibility can be toggled but 75 | is by default closed when the datetime picker is opened. Additional 76 | configuration can be achieved via `TimePickerSettings`. 77 | 78 | `AlwaysVisible` - The time picker is always visible. This is the default setting 79 | as it most explicitly shows that the datetime picker is indeed picking both 80 | a date and time, not simply a date. Additional configuration can be achieved 81 | via `TimePickerSettings`. 82 | 83 | -} 84 | type TimePickerVisibility 85 | = NeverVisible 86 | | Toggleable TimePickerSettings 87 | | AlwaysVisible TimePickerSettings 88 | 89 | 90 | {-| The type facilitating the configuration of the timepicker settings. 91 | 92 | `timeStringFn` - a function that returns a string representation of the selected time of day 93 | 94 | Because it could be the case that a picker is being used in a different 95 | timezone than the home timezone of the implementor, the `allowedTimesofDay` 96 | function ingests a `Zone` in addition to a `Posix`. The 97 | `Zone` represents the time zone in which the picker is being used. An 98 | implementor can leverage this to compare against a base time zone when 99 | enforcing allowable times of day, etc. You SHOULD assume that the `Posix` 100 | passed into these functions is floored to the start of its respective `Day`. 101 | 102 | More information can be found in the [examples](https://github.com/mercurymedia/elm-datetime-picker/tree/master/examples). 103 | 104 | -} 105 | type alias TimePickerSettings = 106 | { timeStringFn : Zone -> Posix -> String 107 | , allowedTimesOfDay : 108 | Zone 109 | -> Posix 110 | -> 111 | { startHour : Int 112 | , startMinute : Int 113 | , endHour : Int 114 | , endMinute : Int 115 | } 116 | } 117 | 118 | 119 | {-| A shared type facilitating the preset variants for both pickers 120 | -} 121 | type Preset 122 | = PresetDate PresetDateConfig 123 | | PresetRange PresetRangeConfig 124 | 125 | 126 | {-| Set type facilitating the preset dates 127 | `title` - the displayed name of the preset 128 | `date` - the `Posix` the preset will select in the date picker. 129 | -} 130 | type alias PresetDateConfig = 131 | { title : String 132 | , date : Posix 133 | } 134 | 135 | 136 | {-| Set type facilitating the preset date ranges 137 | `title` - the displayed name of the preset 138 | `range` - the time range the date range picker will select, consisting of 139 | a start-`Posix` and an end-`Posix`. 140 | -} 141 | type alias PresetRangeConfig = 142 | { title : String 143 | , range : { start : Posix, end : Posix } 144 | } 145 | 146 | 147 | {-| A record of default settings for the date picker. Extend this if 148 | you want to further customize the date picker. 149 | 150 | Requires a `Zone` to inform the picker in which time zone it should 151 | display the selected duration as well as a `msg` that expects a tuple containing 152 | a datepicker instance and a `Maybe` tuple representing a selected duration. 153 | 154 | ( DatePicker, Maybe ( Posix, Posix ) ) -> msg 155 | 156 | -} 157 | defaultSettings : Zone -> Settings 158 | defaultSettings zone = 159 | { zone = zone 160 | , id = "date-picker-component" 161 | , firstWeekDay = Mon 162 | , formattedDay = Utilities.dayToNameString 163 | , formattedMonth = Utilities.monthToNameString 164 | , isDayDisabled = \_ _ -> False 165 | , focusedDate = Nothing 166 | , dateStringFn = \_ _ -> "" 167 | , timePickerVisibility = AlwaysVisible defaultTimePickerSettings 168 | , showCalendarWeekNumbers = False 169 | , presets = [] 170 | , theme = Theme.defaultTheme 171 | , dateInputSettings = DateInput.defaultSettings 172 | } 173 | 174 | 175 | {-| A record of default settings for the time picker. Extend this if 176 | you want to further customize the time picker. 177 | -} 178 | defaultTimePickerSettings : TimePickerSettings 179 | defaultTimePickerSettings = 180 | { timeStringFn = \_ _ -> "", allowedTimesOfDay = \_ _ -> { startHour = 0, startMinute = 0, endHour = 23, endMinute = 59 } } 181 | 182 | 183 | {-| Transforms a `Posix` into a `PickerDay` based on the `Settings` 184 | -} 185 | generatePickerDay : Settings -> Posix -> PickerDay 186 | generatePickerDay settings time = 187 | Maybe.map 188 | (\timePickerSettings -> 189 | Utilities.pickerDayFromPosix settings.zone settings.isDayDisabled (Just timePickerSettings.allowedTimesOfDay) time 190 | ) 191 | (getTimePickerSettings settings) 192 | |> Maybe.withDefault (Utilities.pickerDayFromPosix settings.zone settings.isDayDisabled Nothing time) 193 | 194 | 195 | {-| Extracts the `TimePickerSettings` from the `Settings` 196 | -} 197 | getTimePickerSettings : Settings -> Maybe TimePickerSettings 198 | getTimePickerSettings settings = 199 | case settings.timePickerVisibility of 200 | NeverVisible -> 201 | Nothing 202 | 203 | Toggleable timePickerSettings -> 204 | Just timePickerSettings 205 | 206 | AlwaysVisible timePickerSettings -> 207 | Just timePickerSettings 208 | 209 | 210 | {-| Determines whether the time picker is visible based on the `TimePickerVisibility`. 211 | -} 212 | isTimePickerVisible : TimePickerVisibility -> Bool 213 | isTimePickerVisible timePickerVisibility = 214 | case timePickerVisibility of 215 | NeverVisible -> 216 | False 217 | 218 | Toggleable _ -> 219 | False 220 | 221 | AlwaysVisible _ -> 222 | True 223 | 224 | 225 | {-| Determines if a selected date matches a given preset 226 | -} 227 | isPresetDateActive : Settings -> Maybe ( PickerDay, Posix ) -> PresetDateConfig -> Bool 228 | isPresetDateActive settings selectionTuple { date } = 229 | case selectionTuple of 230 | Just ( pickerDay, _ ) -> 231 | let 232 | presetPickerDay = 233 | generatePickerDay settings date 234 | in 235 | if presetPickerDay == pickerDay then 236 | True 237 | 238 | else 239 | False 240 | 241 | _ -> 242 | False 243 | 244 | 245 | {-| Determines if a selected date range matches a given preset range 246 | -} 247 | isPresetRangeActive : Settings -> Maybe ( PickerDay, Posix ) -> Maybe ( PickerDay, Posix ) -> PresetRangeConfig -> Bool 248 | isPresetRangeActive settings startSelectionTuple endSelectionTuple { range } = 249 | case ( startSelectionTuple, endSelectionTuple ) of 250 | ( Just ( startPickerDay, _ ), Just ( endPickerDay, _ ) ) -> 251 | let 252 | presetStartPickerDay = 253 | generatePickerDay settings range.start 254 | 255 | presetEndPickerDay = 256 | generatePickerDay settings range.end 257 | in 258 | if presetStartPickerDay == startPickerDay && presetEndPickerDay == endPickerDay then 259 | True 260 | 261 | else 262 | False 263 | 264 | _ -> 265 | False 266 | -------------------------------------------------------------------------------- /src/DatePicker/SingleUtilities.elm: -------------------------------------------------------------------------------- 1 | module DatePicker.SingleUtilities exposing 2 | ( selectDay, selectHour, selectMinute 3 | , filterSelectableTimes 4 | , SelectionValue(..), selectTime 5 | ) 6 | 7 | {-| Utility functions specific to the SingleDatePicker. 8 | 9 | 10 | # Making a selection 11 | 12 | @docs selectDay, selectHour, selectMinute 13 | 14 | 15 | # Queries 16 | 17 | @docs filterSelectableTimes 18 | 19 | -} 20 | 21 | import DatePicker.Utilities as Utilities exposing (PickerDay) 22 | import Time exposing (Month(..), Posix, Weekday(..), Zone) 23 | 24 | 25 | type SelectionValue 26 | = Day PickerDay 27 | | Hour Int 28 | | Minute Int 29 | 30 | 31 | {-| Select a time. 32 | 33 | Wrapper function to determine which selection function to call 34 | based on the `SelectionValue` provided. 35 | 36 | -} 37 | selectTime : Zone -> PickerDay -> SelectionValue -> Maybe ( PickerDay, Posix ) -> Maybe ( PickerDay, Posix ) 38 | selectTime zone baseDay selectionValue currentSelectionTuple = 39 | case selectionValue of 40 | Day pickerDay -> 41 | selectDay zone currentSelectionTuple pickerDay 42 | 43 | Hour hour -> 44 | selectHour zone baseDay currentSelectionTuple hour 45 | 46 | Minute minute -> 47 | selectMinute zone baseDay currentSelectionTuple minute 48 | 49 | 50 | {-| Select a day. 51 | 52 | If there is a prior selection and the selection's 53 | time of day falls within the bounds of the newly selected day, the time 54 | is transferred to the new selection. Otherwise, the start bound of the 55 | newly selected day is used as the selection time of day. 56 | 57 | Returns a `Maybe` tuple containing the selected `PickerDay` and a `Posix` representing 58 | the full selection (day + time of day). 59 | 60 | -} 61 | selectDay : Zone -> Maybe ( PickerDay, Posix ) -> PickerDay -> Maybe ( PickerDay, Posix ) 62 | selectDay zone previousSelectionTuple selectedPickerDay = 63 | if selectedPickerDay.disabled then 64 | previousSelectionTuple 65 | 66 | else 67 | case previousSelectionTuple of 68 | Just ( _, previousSelection ) -> 69 | if Utilities.posixWithinPickerDayBoundaries zone selectedPickerDay previousSelection then 70 | -- keep previously picked time of day 71 | Just ( selectedPickerDay, Utilities.setTimeOfDay zone (Time.toHour zone previousSelection) (Time.toMinute zone previousSelection) 0 selectedPickerDay.start ) 72 | 73 | else 74 | -- use start of picked day 75 | Just ( selectedPickerDay, selectedPickerDay.start ) 76 | 77 | Nothing -> 78 | Just ( selectedPickerDay, selectedPickerDay.start ) 79 | 80 | 81 | {-| Select an hour. 82 | 83 | With a prior selection: 84 | 85 | set: hour -> provided hour 86 | 87 | if prior selected minute is selectable in new hour: maintain selected minute 88 | else: select earliest selectable minute for new hour 89 | 90 | With no prior selection: 91 | 92 | set: day -> base day, hour -> provided hour, minute -> earliest 93 | selectable minute for provided hour 94 | 95 | If the resulting selected time is not valid, the prior selection is returned instead. 96 | 97 | Returns `Just` a tuple containing the selected `PickerDay` and a `Posix` representing 98 | the full selection (day + time of day) or `Nothing`. 99 | 100 | -} 101 | selectHour : Zone -> PickerDay -> Maybe ( PickerDay, Posix ) -> Int -> Maybe ( PickerDay, Posix ) 102 | selectHour zone basePickerDay selectionTuple newHour = 103 | Maybe.withDefault ( basePickerDay, basePickerDay.start ) selectionTuple 104 | |> (\( pickerDay, selection ) -> ( pickerDay, Utilities.setHourNotDay zone newHour selection )) 105 | |> (\( pickerDay, selection ) -> 106 | let 107 | ( earliestSelectableMinute, _ ) = 108 | Utilities.minuteBoundsForSelectedHour zone ( pickerDay, selection ) 109 | in 110 | -- if no prior selection, always select earliest selectable minute for hour 111 | if selectionTuple == Nothing then 112 | ( pickerDay, Utilities.setMinuteNotDay zone earliestSelectableMinute selection ) 113 | -- if prior selection, only select earliest selectable minute for hour if prior 114 | -- selected minute is less than the earliest selectable minute 115 | 116 | else if Time.toMinute zone selection < earliestSelectableMinute then 117 | ( pickerDay, Utilities.setMinuteNotDay zone earliestSelectableMinute selection ) 118 | -- otherwise, keep prior selected minute 119 | 120 | else 121 | ( pickerDay, selection ) 122 | ) 123 | |> Utilities.validSelectionOrDefault zone selectionTuple 124 | 125 | 126 | {-| Select a minute. 127 | 128 | With a prior selection: 129 | 130 | set: minute -> provided minute 131 | 132 | With no prior selection: 133 | 134 | set: day -> base day, hour -> base day start hour, minute -> provided minute 135 | 136 | If the resulting selected time is not valid, the prior selection is returned instead. 137 | 138 | Returns `Just` a tuple containing the selected `PickerDay` and a `Posix` representing 139 | the full selection (day + time of day) or `Nothing`. 140 | 141 | -} 142 | selectMinute : Zone -> PickerDay -> Maybe ( PickerDay, Posix ) -> Int -> Maybe ( PickerDay, Posix ) 143 | selectMinute zone basePickerDay selectionTuple newMinute = 144 | Maybe.withDefault ( basePickerDay, basePickerDay.start ) selectionTuple 145 | |> (\( pickerDay, selection ) -> ( pickerDay, Utilities.setMinuteNotDay zone newMinute selection )) 146 | |> Utilities.validSelectionOrDefault zone selectionTuple 147 | 148 | 149 | {-| Determine the selectable hours and minutes for either 150 | the currently selected time of day or the base day start time. 151 | -} 152 | filterSelectableTimes : Zone -> PickerDay -> Maybe ( PickerDay, Posix ) -> { selectableHours : List Int, selectableMinutes : List Int } 153 | filterSelectableTimes zone baseDay selectionTuple = 154 | case selectionTuple of 155 | Just ( pickerDay, selection ) -> 156 | let 157 | ( earliestSelectableHour, latestSelectableHour ) = 158 | Utilities.hourBoundsForSelectedMinute zone ( pickerDay, selection ) 159 | 160 | ( earliestSelectableMinute, latestSelectableMinute ) = 161 | Utilities.minuteBoundsForSelectedHour zone ( pickerDay, selection ) 162 | in 163 | { selectableHours = List.range earliestSelectableHour latestSelectableHour 164 | , selectableMinutes = List.range earliestSelectableMinute latestSelectableMinute 165 | } 166 | 167 | Nothing -> 168 | let 169 | ( earliestSelectableHour, latestSelectableHour ) = 170 | Utilities.hourBoundsForSelectedMinute zone ( baseDay, baseDay.start ) 171 | 172 | ( earliestSelectableMinute, latestSelectableMinute ) = 173 | Utilities.minuteBoundsForSelectedHour zone ( baseDay, baseDay.start ) 174 | in 175 | { selectableHours = List.range earliestSelectableHour latestSelectableHour 176 | , selectableMinutes = List.range earliestSelectableMinute latestSelectableMinute 177 | } 178 | -------------------------------------------------------------------------------- /src/DatePicker/Theme.elm: -------------------------------------------------------------------------------- 1 | module DatePicker.Theme exposing (Theme, defaultTheme) 2 | 3 | {-| This module defines the `Theme` type and a `defaultTheme` for a date picker component. 4 | 5 | @docs Theme, defaultTheme 6 | 7 | -} 8 | 9 | import Css 10 | 11 | 12 | {-| The type facilitating the Theme with the most important design tokens 13 | -} 14 | type alias Theme = 15 | { fontSize : 16 | { base : Float 17 | , sm : Float 18 | , xs : Float 19 | , xxs : Float 20 | } 21 | , color : 22 | { text : 23 | { primary : Css.Color 24 | , secondary : Css.Color 25 | , disabled : Css.Color 26 | , error : Css.Color 27 | } 28 | , primary : 29 | { main : Css.Color 30 | , contrastText : Css.Color 31 | , light : Css.Color 32 | } 33 | , background : 34 | { container : Css.Color 35 | , footer : Css.Color 36 | , presets : Css.Color 37 | , input : Css.Color 38 | } 39 | , action : { hover : Css.Color } 40 | , border : Css.Color 41 | } 42 | , size : 43 | { presetsContainer : Float 44 | , day : Float 45 | , iconButton : Float 46 | , inputElement : Float 47 | } 48 | , spacing : { base : Float } 49 | , borderWidth : Float 50 | , borderRadius : 51 | { base : Float 52 | , lg : Float 53 | } 54 | , boxShadow : 55 | { offsetX : Float 56 | , offsetY : Float 57 | , blurRadius : Float 58 | , spreadRadius : Float 59 | , color : Css.Color 60 | } 61 | , zIndex : Int 62 | , transition : { duration : Float } 63 | , classNamePrefix : String 64 | } 65 | 66 | 67 | {-| The default theme that is included in the defaultSettings 68 | -} 69 | defaultTheme : Theme 70 | defaultTheme = 71 | { fontSize = 72 | { base = 16 73 | , sm = 14 74 | , xs = 12 75 | , xxs = 10 76 | } 77 | , color = 78 | { text = 79 | { primary = Css.hex "22292f" 80 | , secondary = Css.rgba 0 0 0 0.5 81 | , disabled = Css.rgba 0 0 0 0.25 82 | , error = Css.hex "#dc3434" 83 | } 84 | , primary = 85 | { main = Css.hex "3490dc" 86 | , contrastText = Css.hex "ffffff" 87 | , light = Css.rgba 52 144 220 0.1 88 | } 89 | , background = 90 | { container = Css.hex "ffffff" 91 | , footer = Css.hex "ffffff" 92 | , presets = Css.hex "ffffff" 93 | , input = Css.hex "ffffff" 94 | } 95 | , action = { hover = Css.rgba 0 0 0 0.08 } 96 | , border = Css.rgba 0 0 0 0.1 97 | } 98 | , size = 99 | { presetsContainer = 150 100 | , day = 36 101 | , iconButton = 32 102 | , inputElement = 32 103 | } 104 | , spacing = { base = 16 } 105 | , borderWidth = 1 106 | , borderRadius = 107 | { base = 3 108 | , lg = 6 109 | } 110 | , boxShadow = 111 | { offsetX = 0 112 | , offsetY = 0 113 | , blurRadius = 5 114 | , spreadRadius = 0 115 | , color = Css.rgba 0 0 0 0.25 116 | } 117 | , zIndex = 100 118 | , transition = { duration = 300 } 119 | , classNamePrefix = "elm-datetimepicker" 120 | } 121 | -------------------------------------------------------------------------------- /src/DatePicker/Utilities.elm: -------------------------------------------------------------------------------- 1 | module DatePicker.Utilities exposing 2 | ( PickerDay, monthData, generateHourOptions, generateMinuteOptions, generateListOfWeekDay 3 | , pickerDayFromPosix, timeOfDayFromPosix, monthToNameString, dayToNameString 4 | , setTimeOfDay, setHourNotDay, setMinuteNotDay 5 | , calculateViewOffset, hourBoundsForSelectedMinute, minuteBoundsForSelectedHour, posixWithinPickerDayBoundaries, validSelectionOrDefault 6 | , classPrefix, clickedOutsidePicker, eventIsOutsideComponents, monthToNumber, posixWithinTimeBoundaries, showHoveredIfEnabled, toStyledAttrs, updateDomElements 7 | ) 8 | 9 | {-| Utility functions for both Pickers. 10 | 11 | 12 | # View Types & Functions 13 | 14 | @docs PickerDay, monthData, generateHourOptions, generateMinuteOptions, generateListOfWeekDay 15 | 16 | 17 | # Conversions 18 | 19 | @docs pickerDayFromPosix, timeOfDayFromPosix, monthToNameString, dayToNameString 20 | 21 | 22 | # Making a selection 23 | 24 | @docs setTimeOfDay, setHourNotDay, setMinuteNotDay 25 | 26 | 27 | # Queries 28 | 29 | @docs calculateViewOffset, eventIsOutsideComponent, hourBoundsForSelectedMinute, minuteBoundsForSelectedHour, posixWithinPickerDayBoundaries, validSelectionOrDefault 30 | 31 | -} 32 | 33 | import Browser.Dom as Dom 34 | import Html 35 | import Html.Styled exposing (..) 36 | import Html.Styled.Attributes as Attrs exposing (selected, value) 37 | import Json.Decode as Decode 38 | import List.Extra as List 39 | import Task 40 | import Time exposing (Month(..), Posix, Weekday(..), Zone) 41 | import Time.Extra as Time exposing (Interval(..)) 42 | 43 | 44 | 45 | -- VIEW TYPES & FUNCTIONS 46 | 47 | 48 | {-| The type representing a day within the picker. 49 | 50 | Includes selectable time boundaries & whether or 51 | not the day is disabled (not selectable). 52 | 53 | -} 54 | type alias PickerDay = 55 | { start : Posix 56 | , end : Posix 57 | , disabled : Bool 58 | } 59 | 60 | 61 | classPrefix : String -> String -> String 62 | classPrefix prefix className = 63 | prefix ++ "--" ++ className 64 | 65 | 66 | {-| Generate a month to be rendered by the picker 67 | based on the provided `Posix` time. 68 | 69 | Returns a list of weeks which are themselves a 70 | list of `PickerDay`s 71 | 72 | -} 73 | monthData : 74 | Zone 75 | -> (Zone -> Posix -> Bool) 76 | -> Weekday 77 | -> Maybe (Zone -> Posix -> { startHour : Int, startMinute : Int, endHour : Int, endMinute : Int }) 78 | -> Posix 79 | -> List (List PickerDay) 80 | monthData zone isDisabledFn firstWeekDay allowableTimesFn time = 81 | let 82 | monthStart = 83 | Time.floor Month zone time 84 | 85 | monthStartDay = 86 | Time.toWeekday zone monthStart 87 | 88 | monthEnd = 89 | Time.add Day -1 zone (Time.add Month 1 zone monthStart) 90 | 91 | monthEndDay = 92 | Time.toWeekday zone monthEnd 93 | 94 | frontPad = 95 | Time.range Day 1 zone (Time.add Day (calculatePad firstWeekDay monthStartDay True) zone monthStart) monthStart 96 | 97 | endPad = 98 | Time.range Day 1 zone monthEnd (Time.add Day (calculatePad firstWeekDay monthEndDay False) zone monthEnd) 99 | in 100 | (frontPad 101 | ++ Time.range Day 1 zone monthStart monthEnd 102 | ++ endPad 103 | ) 104 | |> monthDataToPickerDays zone isDisabledFn allowableTimesFn 105 | |> splitIntoWeeks [] 106 | |> List.reverse 107 | 108 | 109 | monthDataToPickerDays : 110 | Zone 111 | -> (Zone -> Posix -> Bool) 112 | -> Maybe (Zone -> Posix -> { startHour : Int, startMinute : Int, endHour : Int, endMinute : Int }) 113 | -> List Posix 114 | -> List PickerDay 115 | monthDataToPickerDays zone isDisabledFn allowableTimesFn posixList = 116 | List.map 117 | (\posix -> 118 | pickerDayFromPosix zone isDisabledFn allowableTimesFn posix 119 | ) 120 | posixList 121 | 122 | 123 | splitIntoWeeks : List (List PickerDay) -> List PickerDay -> List (List PickerDay) 124 | splitIntoWeeks weeks days = 125 | if List.length days <= 7 then 126 | days :: weeks 127 | 128 | else 129 | let 130 | ( week, restOfDays ) = 131 | List.splitAt 7 days 132 | 133 | newWeeks = 134 | week :: weeks 135 | in 136 | splitIntoWeeks newWeeks restOfDays 137 | 138 | 139 | {-| Generate a list of Weekday based on the first week day 140 | set in the settings. 141 | -} 142 | generateListOfWeekDay : Weekday -> List Weekday 143 | generateListOfWeekDay firstWeekDay = 144 | case firstWeekDay of 145 | Mon -> 146 | [ Mon, Tue, Wed, Thu, Fri, Sat, Sun ] 147 | 148 | Tue -> 149 | [ Tue, Wed, Thu, Fri, Sat, Sun, Mon ] 150 | 151 | Wed -> 152 | [ Wed, Thu, Fri, Sat, Sun, Mon, Tue ] 153 | 154 | Thu -> 155 | [ Thu, Fri, Sat, Sun, Mon, Tue, Wed ] 156 | 157 | Fri -> 158 | [ Fri, Sat, Sun, Mon, Tue, Wed, Thu ] 159 | 160 | Sat -> 161 | [ Sat, Sun, Mon, Tue, Wed, Thu, Fri ] 162 | 163 | Sun -> 164 | [ Sun, Mon, Tue, Wed, Thu, Fri, Sat ] 165 | 166 | 167 | {-| Performs the tasks of finding the trigger and picker DOM elements 168 | -} 169 | updateDomElements : 170 | { triggerElementId : String 171 | , pickerElementId : String 172 | , onSuccess : { triggerDomElement : Dom.Element, pickerDomElement : Dom.Element } -> msg 173 | , onError : msg 174 | } 175 | -> Cmd msg 176 | updateDomElements { triggerElementId, pickerElementId, onSuccess, onError } = 177 | Task.attempt 178 | (\result -> 179 | case result of 180 | Ok [ triggerEl, pickerEl ] -> 181 | onSuccess { triggerDomElement = triggerEl, pickerDomElement = pickerEl } 182 | 183 | _ -> 184 | -- Dom element not found 185 | onError 186 | ) 187 | (Task.sequence [ Dom.getElement triggerElementId, Dom.getElement pickerElementId ]) 188 | 189 | 190 | toStyledAttrs : List (Html.Attribute msg) -> List (Html.Styled.Attribute msg) 191 | toStyledAttrs attrs = 192 | List.map (\attr -> Attrs.fromUnstyled attr) attrs 193 | 194 | 195 | {-| Generate a list of Html `option`s representing 196 | selectable hours based on the provided selectable hours 197 | list. 198 | -} 199 | generateHourOptions : Zone -> Maybe ( PickerDay, Posix ) -> List Int -> List (Html.Styled.Html msg) 200 | generateHourOptions zone selectionTuple selectableHours = 201 | let 202 | isSelected = 203 | \h -> Maybe.map (\( _, selection ) -> Time.toHour zone selection == h) selectionTuple |> Maybe.withDefault False 204 | in 205 | selectableHours 206 | |> List.map (\hour -> option [ value (String.fromInt hour), selected (isSelected hour) ] [ text (addLeadingZero hour) ]) 207 | 208 | 209 | {-| Generate a list of Html `option`s representing 210 | selectable minutes based on the provided selectable minutes 211 | list. 212 | -} 213 | generateMinuteOptions : Zone -> Maybe ( PickerDay, Posix ) -> List Int -> List (Html.Styled.Html msg) 214 | generateMinuteOptions zone selectionTuple selectableMinutes = 215 | let 216 | isSelected = 217 | \m -> Maybe.map (\( _, selection ) -> Time.toMinute zone selection == m) selectionTuple |> Maybe.withDefault False 218 | in 219 | selectableMinutes 220 | |> List.map (\minute -> option [ value (String.fromInt minute), selected (isSelected minute) ] [ text (addLeadingZero minute) ]) 221 | 222 | 223 | addLeadingZero : Int -> String 224 | addLeadingZero value = 225 | let 226 | string = 227 | String.fromInt value 228 | in 229 | if String.length string == 1 then 230 | "0" ++ string 231 | 232 | else 233 | string 234 | 235 | 236 | 237 | -- CONVERSIONS 238 | 239 | 240 | {-| Convert the provided `Posix` into a `PickerDay`. 241 | Uses the provided functions to determine whether or 242 | not the day should be disabled in the picker as well 243 | as to determine the allowable times of day. 244 | 245 | If no allowable times function is provided the boundary 246 | times default to the start and end of the day (i.e. 247 | 00:00 & 23:59) 248 | 249 | -} 250 | pickerDayFromPosix : 251 | Zone 252 | -> (Zone -> Posix -> Bool) 253 | -> Maybe (Zone -> Posix -> { startHour : Int, startMinute : Int, endHour : Int, endMinute : Int }) 254 | -> Posix 255 | -> PickerDay 256 | pickerDayFromPosix zone isDisabledFn allowableTimesFn posix = 257 | let 258 | flooredPosix = 259 | Time.floor Day zone posix 260 | 261 | allowableTimes = 262 | Maybe.map (\fn -> fn zone flooredPosix) allowableTimesFn 263 | |> Maybe.withDefault { startHour = 0, startMinute = 0, endHour = 23, endMinute = 59 } 264 | in 265 | { start = setTimeOfDay zone allowableTimes.startHour allowableTimes.startMinute 0 flooredPosix 266 | , end = setTimeOfDay zone allowableTimes.endHour allowableTimes.endMinute 59 flooredPosix 267 | , disabled = isDisabledFn zone (Time.floor Day zone flooredPosix) 268 | } 269 | 270 | 271 | {-| Convert the provided `Posix` into a tuple of 272 | integers representing the selected hour of day and 273 | minute of the hour, in that order. 274 | -} 275 | timeOfDayFromPosix : Zone -> Posix -> ( Int, Int ) 276 | timeOfDayFromPosix zone posix = 277 | ( Time.toHour zone posix, Time.toMinute zone posix ) 278 | 279 | 280 | {-| Convert the provided `Month` type into a string 281 | representing the `Month`'s name. 282 | -} 283 | monthToNameString : Month -> String 284 | monthToNameString month = 285 | case month of 286 | Jan -> 287 | "Jan" 288 | 289 | Feb -> 290 | "Feb" 291 | 292 | Mar -> 293 | "Mar" 294 | 295 | Apr -> 296 | "Apr" 297 | 298 | May -> 299 | "May" 300 | 301 | Jun -> 302 | "Jun" 303 | 304 | Jul -> 305 | "Jul" 306 | 307 | Aug -> 308 | "Aug" 309 | 310 | Sep -> 311 | "Sep" 312 | 313 | Oct -> 314 | "Oct" 315 | 316 | Nov -> 317 | "Nov" 318 | 319 | Dec -> 320 | "Dec" 321 | 322 | 323 | monthToNumber : Month -> Int 324 | monthToNumber month = 325 | case month of 326 | Jan -> 327 | 1 328 | 329 | Feb -> 330 | 2 331 | 332 | Mar -> 333 | 3 334 | 335 | Apr -> 336 | 4 337 | 338 | May -> 339 | 5 340 | 341 | Jun -> 342 | 6 343 | 344 | Jul -> 345 | 7 346 | 347 | Aug -> 348 | 8 349 | 350 | Sep -> 351 | 9 352 | 353 | Oct -> 354 | 10 355 | 356 | Nov -> 357 | 11 358 | 359 | Dec -> 360 | 12 361 | 362 | 363 | {-| Convert the provided `Weekday` type into a string 364 | representing the `Weekday`'s name. 365 | -} 366 | dayToNameString : Weekday -> String 367 | dayToNameString day = 368 | case day of 369 | Mon -> 370 | "Mo" 371 | 372 | Tue -> 373 | "Tu" 374 | 375 | Wed -> 376 | "We" 377 | 378 | Thu -> 379 | "Th" 380 | 381 | Fri -> 382 | "Fr" 383 | 384 | Sat -> 385 | "Sa" 386 | 387 | Sun -> 388 | "Su" 389 | 390 | 391 | 392 | -- MAKING A SELECTION 393 | 394 | 395 | {-| Set the hour and minute of the provided dateTime. 396 | -} 397 | setTimeOfDay : Zone -> Int -> Int -> Int -> Posix -> Posix 398 | setTimeOfDay zone hour minute second timeToUpdate = 399 | let 400 | parts = 401 | Time.posixToParts zone timeToUpdate 402 | 403 | newParts = 404 | { parts | hour = hour, minute = minute, second = second } 405 | in 406 | Time.partsToPosix zone newParts 407 | 408 | 409 | {-| Set only the hour of the provided dateTime. 410 | -} 411 | setHourNotDay : Zone -> Int -> Posix -> Posix 412 | setHourNotDay zone hour timeToUpdate = 413 | let 414 | parts = 415 | Time.posixToParts zone timeToUpdate 416 | 417 | newParts = 418 | { parts | hour = hour } 419 | in 420 | Time.partsToPosix zone newParts 421 | 422 | 423 | {-| Set only the minute of the provided dateTime. 424 | -} 425 | setMinuteNotDay : Zone -> Int -> Posix -> Posix 426 | setMinuteNotDay zone minute timeToUpdate = 427 | let 428 | parts = 429 | Time.posixToParts zone timeToUpdate 430 | 431 | newParts = 432 | { parts | minute = minute } 433 | in 434 | Time.partsToPosix zone newParts 435 | 436 | 437 | 438 | -- QUERIES 439 | 440 | 441 | {-| Determine the offset in months between the base/reference time 442 | and the selected time, if any. 443 | -} 444 | calculateViewOffset : Zone -> Posix -> Maybe Posix -> Int 445 | calculateViewOffset zone referenceTime subjectTime = 446 | let 447 | flooredReference = 448 | Time.floor Month zone referenceTime 449 | in 450 | case subjectTime of 451 | Nothing -> 452 | 0 453 | 454 | Just time -> 455 | let 456 | flooredSubject = 457 | Time.floor Month zone time 458 | in 459 | if Time.posixToMillis flooredReference <= Time.posixToMillis flooredSubject then 460 | Time.diff Month zone flooredReference flooredSubject 461 | 462 | else 463 | 0 - Time.diff Month zone flooredSubject flooredReference 464 | 465 | 466 | clickedOutsidePicker : List String -> msg -> Decode.Decoder msg 467 | clickedOutsidePicker componentIds msg = 468 | Decode.field "target" (eventIsOutsideComponents componentIds) 469 | |> Decode.andThen 470 | (\isOutside -> 471 | if isOutside then 472 | Decode.succeed <| msg 473 | 474 | else 475 | Decode.fail "inside component" 476 | ) 477 | 478 | 479 | {-| Determine if the user has clicked outside of the datepicker component. 480 | -} 481 | eventIsOutsideComponents : List String -> Decode.Decoder Bool 482 | eventIsOutsideComponents componentIds = 483 | Decode.oneOf 484 | [ Decode.field "id" Decode.string 485 | |> Decode.andThen 486 | (\id -> 487 | if List.member id componentIds then 488 | -- found match by id 489 | Decode.succeed False 490 | 491 | else 492 | -- try next decoder 493 | Decode.fail "check parent node" 494 | ) 495 | , Decode.lazy (\_ -> eventIsOutsideComponents componentIds |> Decode.field "parentNode") 496 | 497 | -- fallback if all previous decoders failed 498 | , Decode.succeed True 499 | ] 500 | 501 | 502 | {-| Determine the start and end hour boundaries for the selected minute of hour of day. 503 | -} 504 | hourBoundsForSelectedMinute : Zone -> ( PickerDay, Posix ) -> ( Int, Int ) 505 | hourBoundsForSelectedMinute zone ( pickerDay, selection ) = 506 | let 507 | ( startBoundaryHour, startBoundaryMinute ) = 508 | timeOfDayFromPosix zone pickerDay.start 509 | 510 | ( endBoundaryHour, endBoundaryMinute ) = 511 | timeOfDayFromPosix zone pickerDay.end 512 | 513 | ( _, selectedMinute ) = 514 | timeOfDayFromPosix zone selection 515 | 516 | earliestSelectableHour = 517 | if selectedMinute < startBoundaryMinute then 518 | -- it start and end hour bounds are same hour 519 | -- it is impossible to select an invalid minute 520 | -- for an hour option, so we can safely assume 521 | -- that is not the case here and bump the earliest 522 | -- selectable hour back by one 523 | startBoundaryHour + 1 524 | 525 | else 526 | startBoundaryHour 527 | 528 | latestSelectableHour = 529 | if selectedMinute > endBoundaryMinute then 530 | -- it start and end hour bounds are same hour 531 | -- it is impossible to select an invalid minute 532 | -- for an hour option, so we can safely assume 533 | -- that is not the case here and bump the latest 534 | -- selectable hour forward by one 535 | endBoundaryHour - 1 536 | 537 | else 538 | endBoundaryHour 539 | in 540 | ( earliestSelectableHour, latestSelectableHour ) 541 | 542 | 543 | {-| Determine the start and end minute boundaries for the selected hour of day. 544 | -} 545 | minuteBoundsForSelectedHour : Zone -> ( PickerDay, Posix ) -> ( Int, Int ) 546 | minuteBoundsForSelectedHour zone ( pickerDay, selection ) = 547 | let 548 | ( startBoundaryHour, startBoundaryMinute ) = 549 | timeOfDayFromPosix zone pickerDay.start 550 | 551 | ( endBoundaryHour, endBoundaryMinute ) = 552 | timeOfDayFromPosix zone pickerDay.end 553 | 554 | ( selectedHour, selectedMinute ) = 555 | timeOfDayFromPosix zone selection 556 | 557 | earliestSelectableMinute = 558 | if startBoundaryHour == selectedHour then 559 | startBoundaryMinute 560 | 561 | else 562 | 0 563 | 564 | latestSelectableMinute = 565 | if endBoundaryHour == selectedHour then 566 | endBoundaryMinute 567 | 568 | else 569 | 59 570 | in 571 | ( earliestSelectableMinute, latestSelectableMinute ) 572 | 573 | 574 | {-| Determine if the provided `Posix` falls within the provided `PickerDay` time boundaries. 575 | 576 | Will return True even if the `Posix` is from a different calendar day. All this function 577 | cares about is that the provided `Posix` time of day falls within the \`PickerDay\`\` 578 | allowable times. 579 | 580 | -} 581 | posixWithinPickerDayBoundaries : Zone -> PickerDay -> Posix -> Bool 582 | posixWithinPickerDayBoundaries zone pickerDay selection = 583 | let 584 | ( startHour, startMinute ) = 585 | timeOfDayFromPosix zone pickerDay.start 586 | 587 | ( endHour, endMinute ) = 588 | timeOfDayFromPosix zone pickerDay.end 589 | in 590 | posixWithinTimeBoundaries zone 591 | { startHour = startHour 592 | , startMinute = startMinute 593 | , endHour = endHour 594 | , endMinute = endMinute 595 | } 596 | selection 597 | 598 | 599 | posixWithinTimeBoundaries : Zone -> { startHour : Int, startMinute : Int, endHour : Int, endMinute : Int } -> Posix -> Bool 600 | posixWithinTimeBoundaries zone { startHour, startMinute, endHour, endMinute } selection = 601 | let 602 | ( selectionHour, selectionMinute ) = 603 | timeOfDayFromPosix zone selection 604 | in 605 | (startHour == selectionHour && startMinute <= selectionMinute) 606 | || (startHour < selectionHour && selectionHour < endHour) 607 | || (selectionHour == endHour && selectionMinute <= endMinute) 608 | 609 | 610 | {-| Determine if the provided `Posix` falls within the provided `PickerDay`. 611 | -} 612 | validSelectionOrDefault : Zone -> Maybe ( PickerDay, Posix ) -> ( PickerDay, Posix ) -> Maybe ( PickerDay, Posix ) 613 | validSelectionOrDefault zone default ( selectionPickerDay, selection ) = 614 | let 615 | selectionDayEqualsPickerDay = 616 | doDaysMatch zone selection selectionPickerDay.start 617 | in 618 | if posixWithinPickerDayBoundaries zone selectionPickerDay selection && not selectionPickerDay.disabled && selectionDayEqualsPickerDay then 619 | Just ( selectionPickerDay, selection ) 620 | 621 | else 622 | default 623 | 624 | 625 | doDaysMatch : Zone -> Posix -> Posix -> Bool 626 | doDaysMatch zone dateTimeOne dateTimeTwo = 627 | let 628 | oneParts = 629 | Time.posixToParts zone dateTimeOne 630 | 631 | twoParts = 632 | Time.posixToParts zone dateTimeTwo 633 | in 634 | oneParts.day == twoParts.day && oneParts.month == twoParts.month && oneParts.year == twoParts.year 635 | 636 | 637 | calculatePad : Weekday -> Weekday -> Bool -> Int 638 | calculatePad firstWeekDay monthStartDay isFrontPad = 639 | let 640 | listOfWeekday = 641 | generateListOfWeekDay firstWeekDay 642 | 643 | calculatedPadInt = 644 | case List.elemIndex monthStartDay listOfWeekday of 645 | Just val -> 646 | if isFrontPad then 647 | -val 648 | 649 | else 650 | 7 - val 651 | 652 | Nothing -> 653 | 0 654 | in 655 | calculatedPadInt 656 | 657 | 658 | showHoveredIfEnabled : PickerDay -> Maybe PickerDay 659 | showHoveredIfEnabled hovered = 660 | if hovered.disabled then 661 | Nothing 662 | 663 | else 664 | Just hovered 665 | -------------------------------------------------------------------------------- /src/SingleDatePicker.elm: -------------------------------------------------------------------------------- 1 | module SingleDatePicker exposing 2 | ( DatePicker, Msg, init, view, viewDateInput, update, subscriptions 3 | , openPicker, closePicker, updatePickerPosition 4 | , isOpen, hasError 5 | ) 6 | 7 | {-| A date picker component for a single datetime. 8 | 9 | 10 | # Architecture 11 | 12 | @docs DatePicker, Msg, init, view, viewDateInput, update, subscriptions 13 | 14 | 15 | # Externally Triggered Actions 16 | 17 | @docs openPicker, closePicker, updatePickerPosition 18 | 19 | 20 | # Query 21 | 22 | @docs isOpen, hasError 23 | 24 | -} 25 | 26 | import Browser.Dom as Dom 27 | import Browser.Events 28 | import Css 29 | import DatePicker.Alignment as Alignment exposing (Alignment) 30 | import DatePicker.DateInput as DateInput 31 | import DatePicker.Settings exposing (..) 32 | import DatePicker.SingleUtilities as SingleUtilities 33 | import DatePicker.Utilities as Utilities exposing (PickerDay, classPrefix) 34 | import DatePicker.ViewComponents exposing (..) 35 | import Html exposing (Html) 36 | import Html.Events.Extra exposing (targetValueIntParse) 37 | import Html.Styled exposing (div, text, toUnstyled) 38 | import Html.Styled.Attributes exposing (class, css, id) 39 | import Html.Styled.Events exposing (onClick) 40 | import Json.Decode as Decode 41 | import List.Extra as List 42 | import Time exposing (Month(..), Posix, Weekday(..), Zone) 43 | import Time.Extra as Time exposing (Interval(..)) 44 | 45 | 46 | {-| The opaque type representing a particular date picker instance. 47 | -} 48 | type DatePicker msg 49 | = DatePicker (Model msg) 50 | 51 | 52 | type alias Model msg = 53 | { status : Status 54 | , hovered : Maybe PickerDay 55 | , internalMsg : Msg -> msg 56 | , viewOffset : Int 57 | , selectionTuple : Maybe ( PickerDay, Posix ) 58 | , alignment : Maybe Alignment 59 | , dateInput : DateInput.DateInput msg 60 | } 61 | 62 | 63 | type Status 64 | = Closed 65 | | Open Bool PickerDay 66 | 67 | 68 | {-| Instantiates and returns a date picker. 69 | -} 70 | init : (Msg -> msg) -> DatePicker msg 71 | init internalMsg = 72 | DatePicker 73 | { status = Closed 74 | , hovered = Nothing 75 | , internalMsg = internalMsg 76 | , viewOffset = 0 77 | , selectionTuple = Nothing 78 | , alignment = Nothing 79 | , dateInput = 80 | DateInput.init 81 | (internalMsg << HandleDateInputUpdate) 82 | } 83 | 84 | 85 | {-| Events external to the picker to which it is subscribed. 86 | -} 87 | subscriptions : Settings -> DatePicker msg -> Sub msg 88 | subscriptions settings (DatePicker model) = 89 | case model.status of 90 | Open _ _ -> 91 | Browser.Events.onMouseDown (Utilities.clickedOutsidePicker [ settings.id, DateInput.containerId (dateInputConfig settings) ] (model.internalMsg Close)) 92 | 93 | Closed -> 94 | Sub.none 95 | 96 | 97 | {-| Open the provided date picker and receive the updated picker instance. Also 98 | takes a default time the picker should center on (in the event a time has not yet 99 | been picked) as well as the picked time. A common example of a default time 100 | would be the datetime for the current day. 101 | -} 102 | openPicker : String -> Settings -> Posix -> Maybe Posix -> DatePicker msg -> ( DatePicker msg, Cmd msg ) 103 | openPicker triggerElementId settings baseTime pickedTime (DatePicker model) = 104 | let 105 | ( ( updatedPicker, _ ), cmd ) = 106 | update settings (OpenPicker baseTime pickedTime triggerElementId) (DatePicker model) 107 | in 108 | ( updatedPicker, cmd ) 109 | 110 | 111 | {-| Close the provided date picker and receive the updated picker instance. 112 | -} 113 | closePicker : DatePicker msg -> DatePicker msg 114 | closePicker (DatePicker model) = 115 | DatePicker { model | status = Closed } 116 | 117 | 118 | {-| Indicates whether the DatePicker is open 119 | -} 120 | isOpen : DatePicker msg -> Bool 121 | isOpen (DatePicker { status }) = 122 | case status of 123 | Open _ _ -> 124 | True 125 | 126 | Closed -> 127 | False 128 | 129 | 130 | {-| Indicates whether the DatePicker's date inputs have a validation error 131 | -} 132 | hasError : DatePicker msg -> Bool 133 | hasError (DatePicker { dateInput }) = 134 | DateInput.hasError dateInput 135 | 136 | 137 | {-| Returns the command to update the trigger & picker DOM elements' instances. 138 | Is used internally but can also be used externally in case of a changing viewport 139 | (e.g. onScroll or onResize). 140 | -} 141 | updatePickerPosition : DatePicker msg -> ( DatePicker msg, Cmd msg ) 142 | updatePickerPosition (DatePicker model) = 143 | let 144 | cmd = 145 | case ( model.status, model.alignment ) of 146 | ( Open _ _, Just alignment ) -> 147 | Alignment.update (model.internalMsg << GotAlignment) alignment 148 | 149 | ( _, _ ) -> 150 | Cmd.none 151 | in 152 | ( DatePicker model, cmd ) 153 | 154 | 155 | {-| Internal Msg's to update the picker. 156 | -} 157 | type Msg 158 | = NextMonth 159 | | PrevMonth 160 | | NextYear 161 | | PrevYear 162 | | SetHoveredDay PickerDay 163 | | ClearHoveredDay 164 | | SetDay PickerDay 165 | | ToggleTimePickerVisibility 166 | | SetHour Int 167 | | SetMinute Int 168 | | Close 169 | | GotAlignment (Result Dom.Error Alignment) 170 | | SetPresetDate PresetDateConfig 171 | | HandleDateInputUpdate DateInput.Msg 172 | | OpenPicker Posix (Maybe Posix) String 173 | 174 | 175 | {-| Update the SingleDatePicker according to the given internal msg. 176 | 177 | Returns the updated picker and the currently selected datetime, if available. 178 | 179 | -} 180 | update : Settings -> Msg -> DatePicker msg -> ( ( DatePicker msg, Maybe Posix ), Cmd msg ) 181 | update settings msg (DatePicker model) = 182 | let 183 | pickedTime = 184 | Maybe.map (\( _, time ) -> time) model.selectionTuple 185 | in 186 | case model.status of 187 | Open timePickerVisible baseDay -> 188 | case msg of 189 | NextMonth -> 190 | ( ( DatePicker { model | viewOffset = model.viewOffset + 1 }, pickedTime ), Cmd.none ) 191 | 192 | PrevMonth -> 193 | ( ( DatePicker { model | viewOffset = model.viewOffset - 1 }, pickedTime ), Cmd.none ) 194 | 195 | NextYear -> 196 | ( ( DatePicker { model | viewOffset = model.viewOffset + 12 }, pickedTime ), Cmd.none ) 197 | 198 | PrevYear -> 199 | ( ( DatePicker { model | viewOffset = model.viewOffset - 12 }, pickedTime ), Cmd.none ) 200 | 201 | SetHoveredDay pickerDay -> 202 | ( ( DatePicker { model | hovered = Just pickerDay }, pickedTime ), Cmd.none ) 203 | 204 | ClearHoveredDay -> 205 | ( ( DatePicker { model | hovered = Nothing }, pickedTime ), Cmd.none ) 206 | 207 | SetDay pickerDay -> 208 | let 209 | newSelectionTuple = 210 | SingleUtilities.selectTime settings.zone baseDay (SingleUtilities.Day pickerDay) model.selectionTuple 211 | in 212 | ( updateSelection settings baseDay newSelectionTuple ( DatePicker model, pickedTime ) 213 | , Cmd.none 214 | ) 215 | 216 | SetHour hour -> 217 | let 218 | newSelectionTuple = 219 | SingleUtilities.selectTime settings.zone baseDay (SingleUtilities.Hour hour) model.selectionTuple 220 | in 221 | ( updateSelection settings baseDay newSelectionTuple ( DatePicker model, pickedTime ) 222 | , Cmd.none 223 | ) 224 | 225 | SetMinute minute -> 226 | let 227 | newSelectionTuple = 228 | SingleUtilities.selectTime settings.zone baseDay (SingleUtilities.Minute minute) model.selectionTuple 229 | in 230 | ( updateSelection settings baseDay newSelectionTuple ( DatePicker model, pickedTime ) 231 | , Cmd.none 232 | ) 233 | 234 | ToggleTimePickerVisibility -> 235 | case settings.timePickerVisibility of 236 | Toggleable _ -> 237 | ( ( DatePicker { model | status = Open (not timePickerVisible) baseDay }, pickedTime ), Cmd.none ) 238 | 239 | _ -> 240 | ( ( DatePicker model, pickedTime ), Cmd.none ) 241 | 242 | Close -> 243 | ( ( DatePicker { model | status = Closed, alignment = Nothing }, pickedTime ), Cmd.none ) 244 | 245 | SetPresetDate presetDate -> 246 | let 247 | presetPickerDay = 248 | generatePickerDay settings presetDate.date 249 | in 250 | ( updateSelection settings baseDay (Just ( presetPickerDay, presetPickerDay.start )) ( DatePicker model, pickedTime ) 251 | , Cmd.none 252 | ) 253 | 254 | GotAlignment result -> 255 | case result of 256 | Ok alignment -> 257 | ( ( DatePicker { model | alignment = Just alignment }, pickedTime ), Cmd.none ) 258 | 259 | Err _ -> 260 | ( ( DatePicker model, pickedTime ), Cmd.none ) 261 | 262 | HandleDateInputUpdate subMsg -> 263 | let 264 | ( updatedDateInput, dateInputCmd ) = 265 | DateInput.update (dateInputConfig settings) subMsg model.dateInput 266 | in 267 | case DateInput.toPosix settings.zone updatedDateInput of 268 | Just posix -> 269 | let 270 | pickerDay = 271 | generatePickerDay settings posix 272 | 273 | newSelectionTuple = 274 | ( pickerDay, posix ) 275 | 276 | ( DatePicker updatedModel, updatedSelection ) = 277 | updateSelection settings baseDay (Just newSelectionTuple) ( DatePicker model, pickedTime ) 278 | in 279 | ( ( DatePicker { updatedModel | dateInput = updatedDateInput }, updatedSelection ), dateInputCmd ) 280 | 281 | Nothing -> 282 | ( ( DatePicker { model | dateInput = updatedDateInput, selectionTuple = Nothing }, Nothing ), dateInputCmd ) 283 | 284 | _ -> 285 | ( ( DatePicker model, pickedTime ), Cmd.none ) 286 | 287 | Closed -> 288 | case msg of 289 | HandleDateInputUpdate subMsg -> 290 | let 291 | ( updatedDateInput, dateInputCmd ) = 292 | DateInput.update (dateInputConfig settings) subMsg model.dateInput 293 | in 294 | ( ( DatePicker { model | dateInput = updatedDateInput }, pickedTime ), dateInputCmd ) 295 | 296 | OpenPicker baseTime pickedTime_ triggerElementId -> 297 | let 298 | basePickerDay = 299 | generatePickerDay settings baseTime 300 | 301 | newSelectionTuple = 302 | Maybe.map (\time -> ( generatePickerDay settings time, time )) pickedTime_ 303 | 304 | timePickerVisible = 305 | isTimePickerVisible settings.timePickerVisibility 306 | 307 | status = 308 | Open timePickerVisible basePickerDay 309 | 310 | ( DatePicker updatedModel, updatedPickedTime ) = 311 | updateSelection settings basePickerDay newSelectionTuple ( DatePicker model, pickedTime ) 312 | in 313 | ( ( DatePicker { updatedModel | status = status }, updatedPickedTime ) 314 | , Alignment.init 315 | { triggerId = triggerElementId 316 | , pickerId = settings.id 317 | } 318 | (model.internalMsg << GotAlignment) 319 | ) 320 | 321 | _ -> 322 | ( ( DatePicker model, pickedTime ), Cmd.none ) 323 | 324 | 325 | updateSelection : Settings -> PickerDay -> Maybe ( PickerDay, Posix ) -> ( DatePicker msg, Maybe Posix ) -> ( DatePicker msg, Maybe Posix ) 326 | updateSelection settings baseDay newSelectionTuple ( DatePicker model, pickedTime ) = 327 | case newSelectionTuple of 328 | Just ( newPickerDay, newSelection ) -> 329 | ( DatePicker 330 | { model 331 | | selectionTuple = Just ( newPickerDay, newSelection ) 332 | , dateInput = DateInput.updateFromPosix (dateInputConfig settings) settings.zone newSelection model.dateInput 333 | , viewOffset = Utilities.calculateViewOffset settings.zone baseDay.start (Just newPickerDay.start) 334 | } 335 | , Just newSelection 336 | ) 337 | 338 | Nothing -> 339 | ( DatePicker model, pickedTime ) 340 | 341 | 342 | determineDateTime : Zone -> Maybe ( PickerDay, Posix ) -> Maybe PickerDay -> Maybe ( PickerDay, Posix ) 343 | determineDateTime zone selectionTuple hoveredDay = 344 | let 345 | hovered = 346 | Maybe.andThen Utilities.showHoveredIfEnabled hoveredDay 347 | in 348 | case hovered of 349 | Just h -> 350 | SingleUtilities.selectDay zone selectionTuple h 351 | 352 | Nothing -> 353 | selectionTuple 354 | 355 | 356 | {-| The date picker view. Simply pass it the configured settings 357 | and the date picker instance you wish to view. 358 | -} 359 | view : Settings -> DatePicker msg -> Html msg 360 | view settings (DatePicker model) = 361 | viewStyled settings (DatePicker model) 362 | |> toUnstyled 363 | 364 | 365 | viewStyled : Settings -> DatePicker msg -> Html.Styled.Html msg 366 | viewStyled settings (DatePicker model) = 367 | case model.status of 368 | Open timePickerVisible baseDay -> 369 | let 370 | styles = 371 | Alignment.pickerStylesFromAlignment settings.theme model.alignment 372 | in 373 | viewContainer settings.theme 374 | [ id settings.id 375 | , class (classPrefix settings.theme.classNamePrefix "single") 376 | , css styles 377 | ] 378 | [ viewPresets [] settings model 379 | , viewPicker [] settings timePickerVisible baseDay model 380 | ] 381 | 382 | Closed -> 383 | text "" 384 | 385 | 386 | dateInputConfig : Settings -> DateInput.Config 387 | dateInputConfig settings = 388 | let 389 | defaultConfig = 390 | DateInput.defaultConfig settings.zone 391 | in 392 | { defaultConfig 393 | | dateInputSettings = settings.dateInputSettings 394 | , isDayDisabled = settings.isDayDisabled 395 | , theme = settings.theme 396 | , id = settings.id ++ "--date-input" 397 | } 398 | 399 | 400 | {-| The date input view with the date picker opening on click. 401 | Pass it the configured settings, the base time, the picked time 402 | and the date picker instance you wish to view. 403 | -} 404 | viewDateInput : List (Html.Attribute msg) -> Settings -> Posix -> Maybe Posix -> DatePicker msg -> Html msg 405 | viewDateInput attrs settings baseTime maybePickedTime (DatePicker model) = 406 | viewDateInputStyled (Utilities.toStyledAttrs attrs) settings baseTime maybePickedTime (DatePicker model) 407 | |> toUnstyled 408 | 409 | 410 | viewDateInputStyled : List (Html.Styled.Attribute msg) -> Settings -> Posix -> Maybe Posix -> DatePicker msg -> Html.Styled.Html msg 411 | viewDateInputStyled attrs settings baseTime maybePickedTime (DatePicker model) = 412 | let 413 | onClickMsg = 414 | model.internalMsg <| 415 | OpenPicker baseTime maybePickedTime (DateInput.containerId <| dateInputConfig settings) 416 | 417 | isPickerOpen = 418 | isOpen (DatePicker model) 419 | in 420 | DateInput.viewContainer settings.theme 421 | (id (DateInput.containerId <| dateInputConfig settings) :: attrs) 422 | [ DateInput.view 423 | [ onClick onClickMsg 424 | , css 425 | (Alignment.dateInputStylesFromAlignment settings.theme 426 | isPickerOpen 427 | (Alignment.calcDateInputWidth settings.theme settings.showCalendarWeekNumbers) 428 | model.alignment 429 | ) 430 | ] 431 | (dateInputConfig settings) 432 | model.dateInput 433 | , case model.status of 434 | Open timePickerVisible baseDay -> 435 | viewContainer settings.theme 436 | [ id settings.id 437 | , class (classPrefix settings.theme.classNamePrefix "single") 438 | , css 439 | (Alignment.applyPickerStyles 440 | (\alignment -> 441 | [ Alignment.pickerGridLayoutFromAlignment alignment 442 | , Alignment.pickerPositionFromAlignment settings.theme alignment 443 | , Alignment.pickerTranslationFromAlignment settings.theme alignment 444 | ] 445 | ) 446 | model.alignment 447 | ) 448 | ] 449 | [ viewPresets 450 | [ css [ Css.property "grid-area" Alignment.gridAreaPresets ] ] 451 | settings 452 | model 453 | , div 454 | [ css [ Css.property "grid-area" Alignment.gridAreaDateInput, Css.padding (Css.px settings.theme.spacing.base) ] ] 455 | [ DateInput.viewPlaceholder (dateInputConfig settings) ] 456 | , viewPicker 457 | [ css [ Css.property "grid-area" Alignment.gridAreaCalendar ] ] 458 | settings 459 | timePickerVisible 460 | baseDay 461 | model 462 | ] 463 | 464 | Closed -> 465 | text "" 466 | ] 467 | 468 | 469 | viewPicker : List (Html.Styled.Attribute msg) -> Settings -> Bool -> PickerDay -> Model msg -> Html.Styled.Html msg 470 | viewPicker attrs settings timePickerVisible baseDay model = 471 | let 472 | offsetTime = 473 | Time.add Month model.viewOffset settings.zone baseDay.start 474 | 475 | monthName = 476 | Time.toMonth settings.zone offsetTime |> settings.formattedMonth 477 | 478 | year = 479 | Time.toYear settings.zone offsetTime |> String.fromInt 480 | 481 | allowedTimesOfDayFn = 482 | Maybe.map .allowedTimesOfDay (getTimePickerSettings settings) 483 | 484 | weeks = 485 | Utilities.monthData settings.zone settings.isDayDisabled settings.firstWeekDay allowedTimesOfDayFn offsetTime 486 | 487 | currentMonth = 488 | Time.posixToParts settings.zone offsetTime |> .month 489 | 490 | dayStylesFn = 491 | \day -> 492 | let 493 | dayParts = 494 | Time.posixToParts settings.zone day.start 495 | 496 | isPicked = 497 | Maybe.map (\( pickerDay, _ ) -> pickerDay == day) model.selectionTuple 498 | |> Maybe.withDefault False 499 | 500 | isFocused = 501 | Maybe.map (\fday -> generatePickerDay settings fday == day) settings.focusedDate 502 | |> Maybe.withDefault False 503 | in 504 | ( singleDayStyles settings.theme 505 | (dayParts.month /= currentMonth) 506 | day.disabled 507 | isPicked 508 | isFocused 509 | , singleDayClasses 510 | settings.theme 511 | (dayParts.month /= currentMonth) 512 | day.disabled 513 | isPicked 514 | isFocused 515 | ) 516 | in 517 | viewPickerContainer settings.theme 518 | attrs 519 | [ viewCalendarContainer settings.theme 520 | [] 521 | [ viewCalendarHeader settings.theme 522 | { yearText = year 523 | , monthText = monthName 524 | , previousYearMsg = Just <| model.internalMsg <| PrevYear 525 | , previousMonthMsg = Just <| model.internalMsg <| PrevMonth 526 | , nextYearMsg = Just <| model.internalMsg <| NextYear 527 | , nextMonthMsg = Just <| model.internalMsg <| NextMonth 528 | , formattedDay = settings.formattedDay 529 | , firstWeekDay = settings.firstWeekDay 530 | , showCalendarWeekNumbers = settings.showCalendarWeekNumbers 531 | } 532 | , viewCalendarMonth settings.theme 533 | { weeks = weeks 534 | , onMouseOutMsg = model.internalMsg ClearHoveredDay 535 | , zone = settings.zone 536 | , showCalendarWeekNumbers = settings.showCalendarWeekNumbers 537 | , dayProps = 538 | { dayStylesFn = dayStylesFn 539 | , onDayClickMsg = \day -> model.internalMsg (SetDay day) 540 | , onDayMouseOverMsg = \day -> model.internalMsg (SetHoveredDay day) 541 | } 542 | } 543 | ] 544 | , viewFooter settings timePickerVisible baseDay model 545 | ] 546 | 547 | 548 | viewPresets : List (Html.Styled.Attribute msg) -> Settings -> Model msg -> Html.Styled.Html msg 549 | viewPresets attrs settings model = 550 | if List.length settings.presets > 0 then 551 | viewPresetsContainer settings.theme 552 | attrs 553 | (List.map 554 | (\preset -> 555 | case preset of 556 | PresetDate config -> 557 | viewPresetTab settings.theme 558 | [] 559 | { title = config.title 560 | , active = isPresetDateActive settings model.selectionTuple config 561 | , onClickMsg = model.internalMsg (SetPresetDate config) 562 | } 563 | 564 | _ -> 565 | text "" 566 | ) 567 | settings.presets 568 | ) 569 | 570 | else 571 | text "" 572 | 573 | 574 | viewFooter : Settings -> Bool -> PickerDay -> Model msg -> Html.Styled.Html msg 575 | viewFooter settings timePickerVisible baseDay model = 576 | let 577 | displayTime = 578 | determineDateTime settings.zone model.selectionTuple model.hovered 579 | 580 | { selectableHours, selectableMinutes } = 581 | SingleUtilities.filterSelectableTimes settings.zone baseDay model.selectionTuple 582 | in 583 | viewFooterContainer settings.theme 584 | [] 585 | [ case displayTime of 586 | Nothing -> 587 | viewEmpty settings.theme 588 | 589 | Just ( _, selection ) -> 590 | let 591 | dateTimeString = 592 | settings.dateStringFn settings.zone selection 593 | 594 | timePickerProps = 595 | { zone = settings.zone 596 | , selectionTuple = displayTime 597 | , onHourChangeDecoder = Decode.map model.internalMsg (Decode.map SetHour targetValueIntParse) 598 | , onMinuteChangeDecoder = Decode.map model.internalMsg (Decode.map SetMinute targetValueIntParse) 599 | , selectableHours = selectableHours 600 | , selectableMinutes = selectableMinutes 601 | } 602 | in 603 | viewFooterBody settings.theme 604 | { dateTimeString = dateTimeString 605 | , timePickerView = 606 | case settings.timePickerVisibility of 607 | NeverVisible -> 608 | text "" 609 | 610 | Toggleable timePickerSettings -> 611 | viewToggleableTimePicker settings.theme 612 | { timePickerProps = timePickerProps 613 | , timeString = timePickerSettings.timeStringFn timePickerProps.zone selection 614 | , isTimePickerVisible = timePickerVisible 615 | , onTimePickerToggleMsg = model.internalMsg ToggleTimePickerVisibility 616 | } 617 | 618 | AlwaysVisible _ -> 619 | viewAlwaysVisibleTimePicker settings.theme 620 | { timePickerProps = timePickerProps } 621 | } 622 | ] 623 | -------------------------------------------------------------------------------- /src/Task/Extra.elm: -------------------------------------------------------------------------------- 1 | module Task.Extra exposing (andMap) 2 | 3 | import Task exposing (Task) 4 | 5 | andMap : Task x a -> Task x (a -> b) -> Task x b 6 | andMap = 7 | Task.map2 (|>) 8 | -------------------------------------------------------------------------------- /tests/AlignmentTest.elm: -------------------------------------------------------------------------------- 1 | module AlignmentTest exposing (suite) 2 | 3 | import DatePicker.Alignment as Alignment 4 | import Expect 5 | import Test exposing (..) 6 | 7 | 8 | suite : Test 9 | suite = 10 | describe "DatePicker.Alignment" 11 | [ describe "calculatePlacement" 12 | [ test "should align to the `Left, Bottom` of the trigger element if there is enough space" <| 13 | \_ -> 14 | let 15 | params = 16 | { triggerX = 10 17 | , triggerY = 10 18 | , triggerWidth = 10 19 | , triggerHeight = 10 20 | , pickerWidth = 20 21 | , pickerHeight = 10 22 | , viewPortWidth = 100 23 | , viewPortHeight = 100 24 | } 25 | 26 | expectedPlacement = 27 | ( Alignment.Left, Alignment.Bottom ) 28 | in 29 | Expect.equal (Alignment.calculatePlacement params) 30 | expectedPlacement 31 | , test "should align to the `Left, Top` of the trigger element if there is not enough space to the bottom" <| 32 | \_ -> 33 | let 34 | params = 35 | { triggerX = 10 36 | , triggerY = 90 37 | , triggerWidth = 10 38 | , triggerHeight = 10 39 | , pickerWidth = 20 40 | , pickerHeight = 10 41 | , viewPortWidth = 100 42 | , viewPortHeight = 100 43 | } 44 | 45 | expectedPlacement = 46 | ( Alignment.Left, Alignment.Top ) 47 | in 48 | Expect.equal (Alignment.calculatePlacement params) 49 | expectedPlacement 50 | , test "should align to the `Right, Bottom` of the trigger element if there is not enough space to the right" <| 51 | \_ -> 52 | let 53 | params = 54 | { triggerX = 90 55 | , triggerY = 10 56 | , triggerWidth = 10 57 | , triggerHeight = 10 58 | , pickerWidth = 20 59 | , pickerHeight = 10 60 | , viewPortWidth = 100 61 | , viewPortHeight = 100 62 | } 63 | 64 | expectedPlacement = 65 | ( Alignment.Right, Alignment.Bottom ) 66 | in 67 | Expect.equal (Alignment.calculatePlacement params) 68 | expectedPlacement 69 | , test "should align to the `Right, Top` of the trigger element if there is not enough space to the right and bottom" <| 70 | \_ -> 71 | let 72 | params = 73 | { triggerX = 90 74 | , triggerY = 90 75 | , triggerWidth = 10 76 | , triggerHeight = 10 77 | , pickerWidth = 20 78 | , pickerHeight = 10 79 | , viewPortWidth = 100 80 | , viewPortHeight = 100 81 | } 82 | 83 | expectedPlacement = 84 | ( Alignment.Right, Alignment.Top ) 85 | in 86 | Expect.equal (Alignment.calculatePlacement params) 87 | expectedPlacement 88 | , test "should align to the `Center, Bottom` of the trigger element if there is not enough space to the right and left" <| 89 | \_ -> 90 | let 91 | params = 92 | { triggerX = 45 93 | , triggerY = 10 94 | , triggerWidth = 10 95 | , triggerHeight = 10 96 | , pickerWidth = 60 97 | , pickerHeight = 10 98 | , viewPortWidth = 100 99 | , viewPortHeight = 100 100 | } 101 | 102 | expectedPlacement = 103 | ( Alignment.Center, Alignment.Bottom ) 104 | in 105 | Expect.equal (Alignment.calculatePlacement params) 106 | expectedPlacement 107 | , test "should align to the `Center, Top` of the trigger element if there is not enough space to the right, left and bottom" <| 108 | \_ -> 109 | let 110 | params = 111 | { triggerX = 45 112 | , triggerY = 90 113 | , triggerWidth = 10 114 | , triggerHeight = 10 115 | , pickerWidth = 60 116 | , pickerHeight = 10 117 | , viewPortWidth = 100 118 | , viewPortHeight = 100 119 | } 120 | 121 | expectedPlacement = 122 | ( Alignment.Center, Alignment.Top ) 123 | in 124 | Expect.equal (Alignment.calculatePlacement params) 125 | expectedPlacement 126 | ] 127 | ] 128 | -------------------------------------------------------------------------------- /tests/DateInputTest.elm: -------------------------------------------------------------------------------- 1 | module DateInputTest exposing (suite) 2 | 3 | import DatePicker.DateInput as DateInput 4 | import Expect 5 | import Html exposing (time) 6 | import Test exposing (..) 7 | import Time exposing (Month(..)) 8 | import Time.Extra as TimeExtra 9 | 10 | 11 | suite : Test 12 | suite = 13 | let 14 | timeZone = 15 | Time.utc 16 | 17 | dateFormat = 18 | { pattern = DateInput.DDMMYYYY 19 | , separator = '/' 20 | , placeholders = { day = 'd', month = 'm', year = 'y' } 21 | } 22 | 23 | timeFormat = 24 | { separator = ':' 25 | , placeholders = { hour = 'h', minute = 'm' } 26 | , allowedTimesOfDay = \_ _ -> { startHour = 10, startMinute = 0, endHour = 15, endMinute = 59 } 27 | } 28 | in 29 | describe "DatePicker.DateInput" 30 | [ describe "partsToPosix" 31 | [ test "date and time parts return a valid posix" <| 32 | \_ -> 33 | let 34 | dateParts = 35 | { day = Just 30, month = Just 1, year = Just 2025 } 36 | 37 | timeParts = 38 | { hour = Just 12, minute = Just 12 } 39 | 40 | expectedPosix = 41 | TimeExtra.partsToPosix timeZone 42 | { year = 2025 43 | , month = Time.Jan 44 | , day = 30 45 | , hour = 12 46 | , minute = 12 47 | , second = 0 48 | , millisecond = 0 49 | } 50 | |> Just 51 | in 52 | Expect.equal (DateInput.partsToPosix timeZone dateParts timeParts) expectedPosix 53 | , test "partially empty date or time parts return `Nothing`" <| 54 | \_ -> 55 | let 56 | dateParts = 57 | { day = Just 30, month = Just 1, year = Nothing } 58 | 59 | timeParts = 60 | { hour = Just 12, minute = Just 12 } 61 | 62 | expectedPosix = 63 | Nothing 64 | in 65 | Expect.equal (DateInput.partsToPosix timeZone dateParts timeParts) expectedPosix 66 | ] 67 | , describe "sanitizeInputValue (Date format)" 68 | [ test "ignores other characters than digits or separators" <| 69 | \_ -> 70 | Expect.equal (DateInput.sanitizeInputValue (DateInput.Date dateFormat) "30/a") "30/" 71 | , test "trims value to maximum length" <| 72 | \_ -> 73 | Expect.equal (DateInput.sanitizeInputValue (DateInput.Date dateFormat) "30/01/20255") "30/01/2025" 74 | ] 75 | , describe "sanitizeInputValue (DateTime format)" 76 | [ test "ignores other characters than digits or separators or spaces" <| 77 | \_ -> 78 | Expect.equal (DateInput.sanitizeInputValue (DateInput.DateTime dateFormat timeFormat) "30/01/2025 12:b") "30/01/2025 12:" 79 | , test "trims value to maximum length" <| 80 | \_ -> 81 | Expect.equal (DateInput.sanitizeInputValue (DateInput.DateTime dateFormat timeFormat) "30/01/2025 12:122") "30/01/2025 12:12" 82 | ] 83 | , describe "inputValueToParts" 84 | [ test "returns all date and time parts for correct dateTime string" <| 85 | \_ -> 86 | let 87 | expectedDateParts = 88 | { day = Just 30, month = Just 1, year = Just 2025 } 89 | 90 | expectedTimeParts = 91 | { hour = Just 12, minute = Just 12 } 92 | in 93 | Expect.equal (DateInput.inputValueToParts (DateInput.DateTime dateFormat timeFormat) "30/01/2025 12:12") 94 | ( expectedDateParts, expectedTimeParts ) 95 | , test "returns only partial date and time parts for parially correct dateTime string" <| 96 | \_ -> 97 | let 98 | expectedDateParts = 99 | { day = Just 30, month = Just 1, year = Nothing } 100 | 101 | expectedTimeParts = 102 | { hour = Just 12, minute = Nothing } 103 | in 104 | Expect.equal (DateInput.inputValueToParts (DateInput.DateTime dateFormat timeFormat) "30/01/ 12:") 105 | ( expectedDateParts, expectedTimeParts ) 106 | ] 107 | , describe "partsToInputValue" 108 | [ test "returns formatted dateTimestring for complete date and time parts" <| 109 | \_ -> 110 | let 111 | dateParts = 112 | { day = Just 30, month = Just 1, year = Just 2025 } 113 | 114 | timeParts = 115 | { hour = Just 12, minute = Just 12 } 116 | in 117 | Expect.equal (DateInput.partsToInputValue (DateInput.DateTime dateFormat timeFormat) dateParts timeParts) 118 | (Just "30/01/2025 12:12") 119 | ] 120 | , describe "catchError" 121 | [ test "returns no error (`Nothing`) with valid date and time parts" <| 122 | \_ -> 123 | let 124 | settings = 125 | { format = DateInput.DateTime dateFormat timeFormat 126 | , getErrorMessage = \_ -> "" 127 | } 128 | 129 | defaultConfig = 130 | DateInput.defaultConfig timeZone 131 | 132 | config = 133 | { defaultConfig 134 | | dateInputSettings = settings 135 | , isDayDisabled = \_ _ -> False 136 | } 137 | 138 | inputValue = 139 | "30/01/2025 12:12" 140 | 141 | dateParts = 142 | { day = Just 30, month = Just 1, year = Just 2025 } 143 | 144 | timeParts = 145 | { hour = Just 12, minute = Just 12 } 146 | in 147 | Expect.equal (DateInput.catchError config inputValue dateParts timeParts) 148 | Nothing 149 | , test "returns `ValueInvalid` error with invalid date parts" <| 150 | \_ -> 151 | let 152 | settings = 153 | { format = DateInput.DateTime dateFormat timeFormat 154 | , getErrorMessage = \_ -> "" 155 | } 156 | 157 | defaultConfig = 158 | DateInput.defaultConfig timeZone 159 | 160 | config = 161 | { defaultConfig 162 | | dateInputSettings = settings 163 | , isDayDisabled = \_ _ -> False 164 | } 165 | 166 | inputValue = 167 | "33/01/2025 12:12" 168 | 169 | dateParts = 170 | { day = Just 33, month = Just 1, year = Just 2025 } 171 | 172 | timeParts = 173 | { hour = Just 12, minute = Just 12 } 174 | in 175 | Expect.equal (DateInput.catchError config inputValue dateParts timeParts) 176 | (Just DateInput.ValueInvalid) 177 | , test "returns `ValueInvalid` error with invalid time parts" <| 178 | \_ -> 179 | let 180 | settings = 181 | { format = DateInput.DateTime dateFormat timeFormat 182 | , getErrorMessage = \_ -> "" 183 | } 184 | 185 | defaultConfig = 186 | DateInput.defaultConfig timeZone 187 | 188 | config = 189 | { defaultConfig 190 | | dateInputSettings = settings 191 | , isDayDisabled = \_ _ -> False 192 | } 193 | 194 | inputValue = 195 | "30/01/2025 12:72" 196 | 197 | dateParts = 198 | { day = Just 30, month = Just 1, year = Just 2025 } 199 | 200 | timeParts = 201 | { hour = Just 12, minute = Just 72 } 202 | in 203 | Expect.equal (DateInput.catchError config inputValue dateParts timeParts) 204 | (Just DateInput.ValueInvalid) 205 | , test "returns `ValueNotAllowed` error when day is disabled" <| 206 | \_ -> 207 | let 208 | settings = 209 | { format = DateInput.DateTime dateFormat timeFormat 210 | , getErrorMessage = \_ -> "" 211 | } 212 | 213 | defaultConfig = 214 | DateInput.defaultConfig timeZone 215 | 216 | config = 217 | { defaultConfig 218 | | dateInputSettings = settings 219 | , isDayDisabled = \_ _ -> True 220 | } 221 | 222 | inputValue = 223 | "30/01/2025 12:12" 224 | 225 | dateParts = 226 | { day = Just 30, month = Just 1, year = Just 2025 } 227 | 228 | timeParts = 229 | { hour = Just 12, minute = Just 12 } 230 | in 231 | Expect.equal (DateInput.catchError config inputValue dateParts timeParts) 232 | (Just DateInput.ValueNotAllowed) 233 | , test "returns `ValueNotAllowed` error when time is out of allowed bounds" <| 234 | \_ -> 235 | let 236 | settings = 237 | { format = DateInput.DateTime dateFormat timeFormat 238 | , getErrorMessage = \_ -> "" 239 | } 240 | 241 | defaultConfig = 242 | DateInput.defaultConfig timeZone 243 | 244 | config = 245 | { defaultConfig 246 | | dateInputSettings = settings 247 | , isDayDisabled = \_ _ -> False 248 | } 249 | 250 | inputValue = 251 | "30/01/2025 16:12" 252 | 253 | dateParts = 254 | { day = Just 30, month = Just 1, year = Just 2025 } 255 | 256 | timeParts = 257 | { hour = Just 16, minute = Just 12 } 258 | in 259 | Expect.equal (DateInput.catchError config inputValue dateParts timeParts) 260 | (Just DateInput.ValueNotAllowed) 261 | ] 262 | ] 263 | -------------------------------------------------------------------------------- /tests/SingleUtilitiesTest.elm: -------------------------------------------------------------------------------- 1 | module SingleUtilitiesTest exposing (suite) 2 | 3 | import DatePicker.SingleUtilities as SingleUtilities 4 | import Expect 5 | import Test exposing (..) 6 | import Time exposing (Month(..)) 7 | import Time.Extra as Time exposing (Parts, partsToPosix) 8 | 9 | 10 | suite : Test 11 | suite = 12 | let 13 | timeZone = 14 | Time.utc 15 | in 16 | describe "DatePicker.SingleUtilities" 17 | [ describe "when provided day is disabled" 18 | [ test "if provided day is disabled: returns prior selections" <| 19 | \_ -> 20 | let 21 | pickerDay = 22 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 23 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 24 | , disabled = True 25 | } 26 | 27 | priorSelectionTuple = 28 | ( { start = Time.partsToPosix timeZone (Parts 2020 Dec 31 0 0 0 0) 29 | , end = Time.partsToPosix timeZone (Parts 2020 Dec 31 23 59 0 0) 30 | , disabled = False 31 | } 32 | , Time.partsToPosix timeZone (Parts 2020 Dec 31 12 0 0 0) 33 | ) 34 | in 35 | Expect.equal 36 | (SingleUtilities.selectDay timeZone (Just priorSelectionTuple) pickerDay) 37 | (Just priorSelectionTuple) 38 | ] 39 | , describe "selectDay" 40 | [ describe "without prior selection" 41 | [ test "selects the start time of the enclosing (selected) day" <| 42 | \_ -> 43 | let 44 | selectedDay = 45 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 46 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 47 | , disabled = False 48 | } 49 | in 50 | Expect.equal 51 | (SingleUtilities.selectDay timeZone Nothing selectedDay) 52 | (Just ( selectedDay, selectedDay.start )) 53 | ] 54 | , describe "with prior selection" 55 | [ test "maintains the prior selection's time of day if it falls within the bounds of the new selected day" <| 56 | \_ -> 57 | let 58 | selectedDay = 59 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 60 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 61 | , disabled = False 62 | } 63 | 64 | priorSelectionTuple = 65 | ( { start = Time.partsToPosix timeZone (Parts 2020 Dec 31 0 0 0 0) 66 | , end = Time.partsToPosix timeZone (Parts 2020 Dec 31 23 59 0 0) 67 | , disabled = False 68 | } 69 | , Time.partsToPosix timeZone (Parts 2020 Dec 31 12 0 0 0) 70 | ) 71 | in 72 | Expect.equal 73 | (SingleUtilities.selectDay timeZone (Just priorSelectionTuple) selectedDay) 74 | (Just ( selectedDay, Time.partsToPosix timeZone (Parts 2021 Jan 1 12 0 0 0) )) 75 | , test "selects the start of the new selected day when the prior selection's time of day does not fall within the bounds of the new selected day" <| 76 | \_ -> 77 | let 78 | selectedDay = 79 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 80 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 81 | , disabled = False 82 | } 83 | 84 | priorSelectedDay = 85 | { start = Time.partsToPosix timeZone (Parts 2020 Dec 31 0 0 0 0) 86 | , end = Time.partsToPosix timeZone (Parts 2020 Dec 31 23 59 0 0) 87 | , disabled = False 88 | } 89 | 90 | priorSelectionTuple1 = 91 | ( priorSelectedDay 92 | , priorSelectedDay.start 93 | ) 94 | 95 | priorSelectionTuple2 = 96 | ( priorSelectedDay 97 | , priorSelectedDay.end 98 | ) 99 | in 100 | Expect.equal 101 | [ SingleUtilities.selectDay timeZone (Just priorSelectionTuple1) selectedDay, SingleUtilities.selectDay timeZone (Just priorSelectionTuple2) selectedDay ] 102 | [ Just ( selectedDay, selectedDay.start ), Just ( selectedDay, selectedDay.start ) ] 103 | ] 104 | ] 105 | , describe "selectHour" 106 | [ describe "without prior selection" 107 | [ test "selects: day -> base day, hour -> provided hour, minute -> earliest selectable minute for provided hour" <| 108 | \_ -> 109 | let 110 | baseDay = 111 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 112 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 113 | , disabled = False 114 | } 115 | in 116 | Expect.equal 117 | [ SingleUtilities.selectHour timeZone baseDay Nothing 9, SingleUtilities.selectHour timeZone baseDay Nothing 10 ] 118 | [ Just ( baseDay, baseDay.start ), Just ( baseDay, Time.partsToPosix timeZone (Parts 2021 Jan 1 10 0 0 0) ) ] 119 | , test "returns Nothing if the provided hour is not selectable in base day" <| 120 | \_ -> 121 | let 122 | baseDay = 123 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 124 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 125 | , disabled = False 126 | } 127 | in 128 | Expect.equal 129 | [ SingleUtilities.selectHour timeZone baseDay Nothing 8, SingleUtilities.selectHour timeZone baseDay Nothing 18 ] 130 | [ Nothing, Nothing ] 131 | ] 132 | , describe "with prior selection" 133 | [ test "selects: day -> prior date, hour -> provided hour, minute -> prior or earliest selectable minute for provided hour" <| 134 | \_ -> 135 | let 136 | baseDay = 137 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 138 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 139 | , disabled = False 140 | } 141 | 142 | priorSelectionTuple = 143 | ( baseDay 144 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 10 15 0 0) 145 | ) 146 | in 147 | Expect.equal 148 | [ SingleUtilities.selectHour timeZone baseDay (Just priorSelectionTuple) 9, SingleUtilities.selectHour timeZone baseDay (Just priorSelectionTuple) 11 ] 149 | [ Just ( baseDay, baseDay.start ), Just ( baseDay, Time.partsToPosix timeZone (Parts 2021 Jan 1 11 15 0 0) ) ] 150 | , test "returns prior selection if the provided hour is not selectable in prior selection enclosing day" <| 151 | \_ -> 152 | let 153 | baseDay = 154 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 155 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 156 | , disabled = False 157 | } 158 | 159 | priorSelectionTuple = 160 | ( baseDay 161 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 10 15 0 0) 162 | ) 163 | in 164 | Expect.equal 165 | [ SingleUtilities.selectHour timeZone baseDay (Just priorSelectionTuple) 8, SingleUtilities.selectHour timeZone baseDay (Just priorSelectionTuple) 18 ] 166 | [ Just priorSelectionTuple, Just priorSelectionTuple ] 167 | ] 168 | ] 169 | , describe "selectMinute" 170 | [ describe "without prior selection" 171 | [ test "selects: day -> base day, hour -> base day start hour, minute -> provided minute" <| 172 | \_ -> 173 | let 174 | baseDay = 175 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 176 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 177 | , disabled = False 178 | } 179 | in 180 | Expect.equal 181 | (SingleUtilities.selectMinute timeZone baseDay Nothing 45) 182 | (Just ( baseDay, Time.partsToPosix timeZone (Parts 2021 Jan 1 9 45 0 0) )) 183 | , test "returns Nothing if the provided minute is not selectable in base day start hour" <| 184 | \_ -> 185 | let 186 | baseDay = 187 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 188 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 189 | , disabled = False 190 | } 191 | in 192 | Expect.equal 193 | (SingleUtilities.selectMinute timeZone baseDay Nothing 15) 194 | Nothing 195 | ] 196 | , describe "with prior selection" 197 | [ test "selects: day -> prior date, hour -> prior hour, minute -> provided minute" <| 198 | \_ -> 199 | let 200 | baseDay = 201 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 202 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 203 | , disabled = False 204 | } 205 | 206 | priorSelectionTuple = 207 | ( baseDay 208 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 10 15 0 0) 209 | ) 210 | in 211 | Expect.equal 212 | (SingleUtilities.selectMinute timeZone baseDay (Just priorSelectionTuple) 30) 213 | (Just ( baseDay, Time.partsToPosix timeZone (Parts 2021 Jan 1 10 30 0 0) )) 214 | , test "returns prior selection if the resulting time of day from the provided minute is not selectable in prior selection enclosing day" <| 215 | \_ -> 216 | let 217 | baseDay = 218 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 219 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 220 | , disabled = False 221 | } 222 | 223 | priorSelectionTuple1 = 224 | ( baseDay 225 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 226 | ) 227 | 228 | priorSelectionTuple2 = 229 | ( baseDay 230 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 231 | ) 232 | in 233 | Expect.equal 234 | [ SingleUtilities.selectMinute timeZone baseDay (Just priorSelectionTuple1) 15, SingleUtilities.selectMinute timeZone baseDay (Just priorSelectionTuple2) 45 ] 235 | [ Just priorSelectionTuple1, Just priorSelectionTuple2 ] 236 | ] 237 | ] 238 | , describe "filterSelectableTimes" 239 | [ describe "without prior selection" 240 | [ test "filters the selectable hours and minutes based on the start time of the base day" <| 241 | \_ -> 242 | let 243 | baseDay = 244 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 245 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 246 | , disabled = False 247 | } 248 | in 249 | Expect.equal 250 | (SingleUtilities.filterSelectableTimes timeZone baseDay Nothing) 251 | { selectableHours = List.range 9 17, selectableMinutes = List.range 30 59 } 252 | ] 253 | , describe "with prior selection" 254 | [ test "filters the selectable hours and minutes based on the selected time of day" <| 255 | \_ -> 256 | let 257 | baseDay = 258 | { start = Time.partsToPosix timeZone (Parts 2021 Jan 1 9 30 0 0) 259 | , end = Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 260 | , disabled = False 261 | } 262 | 263 | priorSelectionTuple1 = 264 | ( baseDay 265 | , baseDay.start 266 | ) 267 | 268 | priorSelectionTuple2 = 269 | ( baseDay 270 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 12 0 0 0) 271 | ) 272 | 273 | priorSelectionTuple3 = 274 | ( baseDay 275 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 12 45 0 0) 276 | ) 277 | 278 | priorSelectionTuple4 = 279 | ( baseDay 280 | , Time.partsToPosix timeZone (Parts 2021 Jan 1 17 30 0 0) 281 | ) 282 | in 283 | Expect.equal 284 | [ SingleUtilities.filterSelectableTimes timeZone baseDay (Just priorSelectionTuple1) 285 | , SingleUtilities.filterSelectableTimes timeZone baseDay (Just priorSelectionTuple2) 286 | , SingleUtilities.filterSelectableTimes timeZone baseDay (Just priorSelectionTuple3) 287 | , SingleUtilities.filterSelectableTimes timeZone baseDay (Just priorSelectionTuple4) 288 | ] 289 | [ { selectableHours = List.range 9 17, selectableMinutes = List.range 30 59 } 290 | , { selectableHours = List.range 10 17, selectableMinutes = List.range 0 59 } 291 | , { selectableHours = List.range 9 16, selectableMinutes = List.range 0 59 } 292 | , { selectableHours = List.range 9 17, selectableMinutes = List.range 0 30 } 293 | ] 294 | ] 295 | ] 296 | ] 297 | --------------------------------------------------------------------------------