Last active
December 16, 2015 19:10
-
-
Save brianmhunt/5483270 to your computer and use it in GitHub Desktop.
Knockout binding for amsul/pickadate.js
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
# This is just a stub variable to keep track of multiple bindings. | |
# One could just have the bindings.date below directly link to | |
# ko.bindingHandlers, but then you would have to update the unit tests. | |
bindings = {} | |
# This gist depends on the inclusion of libraries: | |
# | |
# lodash (or underscore): http://lodash.com | |
# Used for _.isEqual, _.isDate, _.isString, _.defer | |
# | |
# moment.js: http://momentjs.com | |
# Used for date parsing, manipulation and formatting. | |
### | |
# date binding | |
# ~~~~~~~~~~~~ | |
# | |
# Pick a date. | |
# | |
# TODO: Native mobile datepicker. | |
# | |
# Requirements | |
# 1. select day/month/year in calendar | |
# 2. changing to a different month changes to the respective day in that new | |
# month | |
# 3. openable by icon | |
# 4. observable is set to null or a Date object | |
# 5. page navigation by cursor keys (up/down/etc) is not compromised | |
# 6. manual input | |
#### | |
bindings.date = | |
# | |
# Using `pickadate`, which seems superior to bootstrap/jqueryui options. | |
# element is an <input> tag | |
# | |
# See also | |
# http://stackoverflow.com/questions/6612705 | |
# https://github.com/Aymkdn/Datepicker-for-Bootstrap | |
# http://stackoverflow.com/questions/11121960 | |
init: (element, valueAccessor, allBindingsAccessor, context) -> | |
# initialize datepicker with some optional options | |
$e = $(element) | |
abs = allBindingsAccessor() | |
va = valueAccessor() | |
$dp = $("<input type='date' class='datepicker' tabindex=-1 />") | |
$i = $("<i style='color: navy; cursor: pointer' + | |
title='A calendar appears when interacting with this field' " + | |
"class='add-on icon-calendar'></i>") | |
$e.wrap("<div class='input-append dateholder'></div>") | |
.addClass('dateinput') | |
.after($i) | |
.after($dp) | |
# <div class='dateholder input-append'> | |
# <input type='text' class='dateinput' /> | |
# <i class='icon-*'></i> | |
# <input type='date' tabindex='-1' class='datepicker' /> | |
# </div> | |
cal = $dp.pickadate( | |
onSelect: -> | |
$e.val(@getDate()).trigger('change') | |
@close() | |
monthSelector: true | |
yearSelector: true | |
onRender: -> | |
# console.log "new month!" # ???? XXX FIXME UPSTREAM | |
).data('pickadate') | |
# Save this reference for easy access later. | |
$e.data('pickadate', cal).on( | |
focus: -> $dp.addClass('pseudo-focused') | |
# Defer the blur so that events such as 'click' are passed to the | |
# Today or Clear button. If the calendar is hidden before the click can | |
# be registered, it will not happen | |
blur: -> setTimeout((-> $dp.removeClass('pseudo-focused')), 100) | |
# defer the keydown event until after the browser has updated the | |
# control Note, this is the only place where the value accessor is | |
# updated based on changes to input | |
change: () -> | |
current_date = va() # va() should be a date | |
# new_date is what is currently in the input; convert it to UTC | |
new_date = moment($e.val()).utc() | |
# console.log "updating", current_date, "with", new_date.toDate() | |
if not new_date.toDate() or isNaN(new_date.toDate()) | |
# monkey dies (bad dates). | |
return | |
if not current_date or isNaN(current_date) | |
# set to midnight today. | |
current_date = moment().startOf('day') | |
# Since we are dealing with days, these calculations are in local | |
# time. | |
# Rule #4. | |
current_date = moment.utc(current_date) | |
# XXX BE WARNED - TIMEZONE MAGIC DRAGONS. | |
# | |
# Normalize the hour; if we do not, then when a date is moved across | |
# a DST boundary the event will move forward or backward one hour. | |
# This is not what most users would expect when rescheduling across | |
# the DST line; most expect 6am to be 6am, before or after DST. | |
# | |
# An exception for this is when the normalized hour is exactly | |
# midnight, in which case we will assume that this is a timeless date | |
# object. | |
# | |
# Our timepicker co-ordinates with this by setting the milliseconds | |
# to 1, so that events at midnight will not be considered "timeless" | |
# | |
# A better idea than this magic might be to have a datetime picker | |
# and a date picker as separate bindings. The tz problem persists, | |
# but at least we don't need this DST hour adjustment. | |
# | |
normal_hour = current_date.utc().hour() | |
unless current_date.local().format("HHmmssSSS") == "000000000" | |
current_date.utc().hour(normal_hour) | |
# To preserve any hour/minute/second already in the date, we just | |
# change the Y/M/D | |
current_date.year(new_date.year()) | |
current_date.month(new_date.month()) | |
current_date.date(new_date.date()) | |
# Do not update the dates unless necessary - it may trigger all | |
# sorts of knock-ons in the observables. | |
if _.isEqual(current_date.utc().toDate(), va()) | |
return | |
va(current_date.utc().toDate()) | |
) | |
# Open the datepicker on click. | |
# Defer the opening ... because that's what pickadate needs. | |
$i.on("click", -> _.defer -> cal.open()) | |
# minmum date limits | |
if abs.minDate | |
_set_min_date = (dv) -> | |
unless dv then return | |
d = moment.utc(dv) | |
cal.setDateLimit([d.year(), d.month()+1, d.date()]) | |
if ko.isObservable(abs.minDate) | |
# whenever the min date is changed (or loaded), we update the | |
# calendar restrictions | |
abs.minDate.subscribe (new_date) -> | |
_set_min_date(new_date) | |
_set_min_date(ko.utils.unwrapObservable(abs.minDate)) | |
if abs.maxDate | |
_set_max_date = (dv) -> | |
unless dv then return | |
d = moment.utc(dv) | |
cal.setDateLimit([d.year(), d.month()+1, d.date()], true) | |
if ko.isObservable(abs.maxDate) | |
# whenever the min date is changed (or loaded), we update the | |
# calendar restrictions | |
abs.maxDate.subscribe (new_date) -> | |
_set_max_date(new_date) | |
_set_max_date(ko.utils.unwrapObservable(abs.maxDate)) | |
# ko.utils.domNodeDisposal.addDisposeCallback(element, -> | |
# $e.datepicker("destroy") | |
#) | |
return | |
update: (element, valueAccessor) -> | |
$e = $(element) | |
obs = valueAccessor() | |
format = "MMMM D, YYYY" | |
dateval = ko.utils.unwrapObservable(obs) | |
current = moment.utc($e.val()) | |
cal = $e.data('pickadate') | |
# on any change, wipe any 'error' visual state | |
$e.parent("li, .control-group").removeClass('error') | |
# observable updated with a blank date means we empty the input | |
if not dateval | |
$e.val('') | |
return | |
# convert any string value to a date object - preserving time and date | |
if _.isString(dateval) | |
dateval = moment.utc(dateval) | |
obs(dateval.toDate()) | |
# convert from string or date to a Moment object | |
if _.isDate(dateval) | |
dateval = moment.utc(dateval) | |
# else it ought to be a Moment class instance | |
# check to ensure we were given a valid date | |
if not dateval.isValid() | |
# Go up and mark as an error the current list item or alternatively | |
# the Bootstrap control group | |
$e.parent("li, .control-group").addClass('error') | |
# FIXME - Validation should be separate? | |
return | |
# Nothing to do. What is in the input matches the updated value. | |
if dateval == current and current and\ | |
dateval.format(format) == current.format(format) | |
return | |
localdate = dateval.local() | |
# update the calendar, in case the user is going to change the date with | |
# it | |
cal.setDate(localdate.year(), localdate.month() + 1, localdate.date()) | |
# update the input to the new date, or format of the set date (if | |
# necessary); this triggers the change event above. | |
$e.val(localdate.format(format)) | |
return | |
# | |
# Add the above binding to Knockout | |
# | |
ko.bindingHandlers.pickadate = bindings.date |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
describe "bindings/time: date", -> | |
it "should update with pickadate", -> | |
dt = ko.observable(moment("2011-07-07").toDate()) | |
ab = {} # all bindings | |
$e = $("<input>") | |
bindings.date.init($e, (-> dt), (-> ab)) | |
# update with pickdatae | |
cal = $e.data('pickadate') | |
cal.setDate(2012, 1, 9) | |
mt = moment(dt()) | |
assert.equal(mt.year(), 2012, "unequal years") | |
assert.equal(mt.month(), 0, "unequal months") | |
assert.equal(mt.date(), 9, "unequal days") | |
it "should update with changes to the input", -> | |
dt = ko.observable(moment("2011-07-07").toDate()) | |
ab = {} # all bindings | |
$e = $("<input>") | |
bindings.date.init($e, (-> dt), (-> ab)) | |
# update with pickdatae | |
$e.val("9 Jan 2012").change() | |
mt = moment.utc(dt()) | |
assert.equal(mt.year(), 2012, "unequal years") | |
assert.equal(mt.month(), 0, "unequal months") | |
assert.equal(mt.date(), 9, "unequal days") | |
it "should update a string to a calendar date", -> | |
dt = ko.observable() | |
ab = {} # all bindings | |
$e = $("<input>") | |
bindings.date.init($e, (-> dt), (-> ab)) | |
# pretend we get an update to the observable (i.e. loading) | |
dt(moment("2012-01-09").utc().toDate()) | |
bindings.date.update($e, (-> dt), (-> ab)) | |
# now check the input vaalue | |
mt = moment($e.val()).utc() | |
assert.equal(mt.year(), 2012, "unequal years") | |
assert.equal(mt.month(), 0, "unequal months") | |
assert.equal(mt.date(), 9, "unequal days") | |
it "should not clobber time values", -> | |
dt = ko.observable() | |
ab = {} # all bindings | |
$e = $("<input>") | |
bindings.date.init($e, (->dt), (->ab)) | |
# set a time | |
dt(moment.utc("2009-08-07T06:05:04").toDate()) | |
# do various updates; ensure the time is not clobbered | |
$e.val("9 Jan 2012").change() | |
cal = $e.data('pickadate') | |
cal.setDate(2012, 2, 10) | |
mt = moment.utc(dt()) | |
# NOTE: that the UTC hour would be 7 because the initial date was on | |
# daylight savings time (August), but January is not so the | |
# original (Aug) date is GMT-4, the new (Jan) date is GMT-5. However | |
# we account for this in the binding. | |
assert.equal(mt.hour(), 6, "unequal hour") | |
assert.equal(mt.minute(), 5, "unequal date") | |
assert.equal(mt.seconds(), 4, "unequal seconds") | |
it "should update from ISO strings, and convert the observable to Date", -> | |
dt = ko.observable() | |
ab = {} # all bindings | |
$e = $("<input>") | |
bindings.date.init($e, (->dt), (->ab)) | |
mt = moment.utc("2009-08-07T06:05:04").toDate().toISOString() | |
dt(mt) | |
bindings.date.update($e, (-> dt), (-> ab)) | |
assert.equal(dt().toISOString(), '2009-08-07T06:05:04.000Z') | |
assert.equal($e.val(), "August 7, 2009") |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment