Skip to content

Instantly share code, notes, and snippets.

@brianmhunt
Last active December 16, 2015 19:10
Show Gist options
  • Save brianmhunt/5483270 to your computer and use it in GitHub Desktop.
Save brianmhunt/5483270 to your computer and use it in GitHub Desktop.
Knockout binding for amsul/pickadate.js
# 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
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