├── .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 | 
14 |
15 |
16 | #### Duration Picker
17 |
18 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 | 
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 |
--------------------------------------------------------------------------------