jquery-ui/ui/datepicker.js
Jörn Zaefferer 9ec69ccb73 Datepicker: Fix the eachDay option
Use the `eachDay` option in the other-months demo.
Fix handling of `extraClasses` property, split on space.
2015-01-29 17:47:46 -05:00

621 lines
17 KiB
JavaScript

/*!
* jQuery UI Datepicker @VERSION
* http://jqueryui.com
*
* Copyright jQuery Foundation and other contributors
* Released under the MIT license.
* http://jquery.org/license
*/
//>>label: Datepicker
//>>group: Widgets
//>>description: Displays a calendar from an input or inline for selecting dates.
//>>docs: http://api.jqueryui.com/datepicker/
//>>demos: http://jqueryui.com/datepicker/
(function( factory ) {
if ( typeof define === "function" && define.amd ) {
// AMD. Register as an anonymous module.
define([
"jquery",
"./core",
"./widget",
"./button",
"./position"
], factory );
} else {
// Browser globals
factory( jQuery );
}
}(function( $ ) {
// TODO use uniqueId, if possible
var idIncrement = 0,
// TODO move this to the instance
suppressExpandOnFocus = false;
$.widget( "ui.datepicker", {
options: {
appendTo: null,
dateFormat: null,
// TODO review
eachDay: $.noop,
numberOfMonths: 1,
position: {
my: "left top",
at: "left bottom"
},
showWeek: false,
show: true,
hide: true,
// callbacks
beforeOpen: null,
close: null,
open: null,
select: null
},
_create: function() {
this.date = $.date( null, this.options.dateFormat );
this.date.eachDay = this.options.eachDay;
this.id = "ui-datepicker-" + idIncrement;
idIncrement++;
if ( this.element.is( "input" ) ) {
this._createPicker();
} else {
this.inline = true;
this.picker = this.element;
}
this._on( this.picker, {
"click .ui-datepicker-prev": function( event ) {
event.preventDefault();
this.date.adjust( "M", -this.options.numberOfMonths );
this.refresh();
},
"click .ui-datepicker-next": function( event ) {
event.preventDefault();
this.date.adjust( "M", this.options.numberOfMonths );
this.refresh();
},
"click .ui-datepicker-current": function( event ) {
event.preventDefault();
this.select( event, new Date().getTime() );
},
"click .ui-datepicker-close": function( event ) {
event.preventDefault();
this.close( event );
},
"mousedown .ui-datepicker-calendar a": function( event ) {
event.preventDefault();
// TODO exclude clicks on lead days or handle them correctly
// TODO store/read more then just date, also required for multi month picker
this.select( event, $( event.currentTarget ).data( "timestamp" ) );
if ( this.inline ) {
this.grid.focus();
}
},
"keydown .ui-datepicker-calendar": "_handleKeydown"
});
// TODO use hoverable (no delegation support)? convert to _on?
this.picker.delegate( ".ui-datepicker-header a, .ui-datepicker-calendar a", "mouseenter.datepicker mouseleave.datepicker", function() {
$( this ).toggleClass( "ui-state-hover" );
});
this._createTmpl();
},
_handleKeydown: function( event ) {
if ( jQuery.inArray( event.keyCode, [ 13, 33, 34, 35, 36, 37, 38, 39, 40 ] ) === -1 ) {
//only interested navigation keys
return;
}
event.preventDefault();
var newId, newCell,
activeCell = $( "#" + this.grid.attr( "aria-activedescendant" ) ),
oldMonth = this.date.month(),
oldYear = this.date.year();
// TODO: Handle for pickers with multiple months
switch ( event.keyCode ) {
case $.ui.keyCode.ENTER:
activeCell.children( "a:first" ).mousedown();
return;
case $.ui.keyCode.PAGE_UP:
this.date.adjust( event.altKey ? "Y" : "M", 1 );
break;
case $.ui.keyCode.PAGE_DOWN:
this.date.adjust( event.altKey ? "Y" : "M", -1 );
break;
case $.ui.keyCode.END:
this.date.setDay( this.date.daysInMonth() );
break;
case $.ui.keyCode.HOME:
this.date.setDay( 1 );
break;
case $.ui.keyCode.LEFT:
this.date.adjust( "D", -1 );
break;
case $.ui.keyCode.UP:
this.date.adjust( "D", -7 );
break;
case $.ui.keyCode.RIGHT:
this.date.adjust( "D", 1 );
break;
case $.ui.keyCode.DOWN:
this.date.adjust( "D", 7 );
break;
default:
return;
}
if ( this.date.month() !== oldMonth || this.date.year() !== oldYear ) {
this.refresh();
this.grid.focus();
} else {
newId = this.id + "-" + this.date.day();
newCell = $( "#" + newId );
// TODO unnecessary optimization? is it really needed?
if ( !newCell.length ) {
return;
}
this.grid.attr("aria-activedescendant", newId);
activeCell.children("a").removeClass("ui-state-focus");
newCell.children("a").addClass("ui-state-focus");
}
},
_createPicker: function() {
this.picker = $( "<div>" )
.addClass( "ui-front" )
// TODO add a ui-datepicker-popup class, move position:absolte to that
.css( "position", "absolute" )
.uniqueId()
.hide();
this._setHiddenPicker();
this.picker.appendTo( this._appendTo() );
this.element
.attr( "aria-haspopup", "true" )
.attr( "aria-owns", this.picker.attr( "id" ) );
this._on({
keydown: function( event ) {
switch ( event.keyCode ) {
case $.ui.keyCode.TAB:
// Waiting for close() will make popup hide too late, which breaks tab key behavior
this.picker.hide();
this.close( event );
break;
case $.ui.keyCode.ESCAPE:
if ( this.isOpen ) {
this.close( event );
}
break;
case $.ui.keyCode.DOWN:
case $.ui.keyCode.UP:
clearTimeout( this.closeTimer );
this._delay(function() {
this.open( event );
this.grid.focus();
}, 1);
break;
}
},
mousedown: function( event ) {
if (this.isOpen) {
suppressExpandOnFocus = true;
this.close();
return;
}
this.open( event );
clearTimeout( this.closeTimer );
},
focus: function( event ) {
if ( !suppressExpandOnFocus ) {
this._delay( function() {
if ( !this.isOpen ) {
this.open( event );
}
}, 1);
}
this._delay( function() {
suppressExpandOnFocus = false;
}, 100);
},
blur: function() {
suppressExpandOnFocus = false;
}
});
this._on( this.picker, {
focusout: function( event ) {
// use a timer to allow click to clear it and letting that
// handle the closing instead of opening again
// also allows tabbing inside the calendar without it closing
this.closeTimer = this._delay( function() {
this.close( event );
}, 150);
},
focusin: function() {
clearTimeout( this.closeTimer );
},
mouseup: function() {
clearTimeout( this.closeTimer );
},
// TODO on TAB (or shift TAB), make sure it ends up on something useful in DOM order
keyup: function( event ) {
if ( event.keyCode === $.ui.keyCode.ESCAPE && this.picker.is( ":visible" ) ) {
this.close( event );
this._focusTrigger();
}
}
});
this._on( this.document, {
click: function( event ) {
if ( this.isOpen && !$( event.target ).closest( this.element.add( this.picker ) ).length ) {
this.close( event );
}
}
});
},
_appendTo: function() {
var element = this.options.appendTo;
if ( element ) {
element = element.jquery || element.nodeType ?
$( element ) :
this.document.find( element ).eq( 0 );
}
if ( !element ) {
element = this.element.closest( ".ui-front" );
}
if ( !element.length ) {
element = this.document[0].body;
}
return element;
},
_createTmpl: function() {
this.date.refresh();
this._createDatepicker();
this.picker.find( "button" ).button();
if ( this.inline ) {
this.picker.children().addClass( "ui-datepicker-inline" );
}
// against display:none in datepicker.css
this.picker.find( ".ui-datepicker" ).css( "display", "block" );
this.grid = this.picker.find( ".ui-datepicker-calendar" );
},
_createDatepicker: function() {
var multiClasses = [],
pickerHtml = "";
if (this.options.numberOfMonths === 1 ) {
pickerHtml = this._buildHeader() + this._buildGrid() + this._buildButtons();
} else {
pickerHtml = this._buildMultiplePicker();
multiClasses.push( "ui-datepicker-multi" );
multiClasses.push( "ui-datepicker-multi-" + this.options.numberOfMonths );
}
$( "<div>" )
.addClass( "ui-datepicker ui-widget ui-widget-content ui-helper-clearfix ui-corner-all" )
.addClass( multiClasses.join( " " ) )
.attr({
role: "region",
"aria-labelledby": this.id + "-title"
})
.html( pickerHtml )
.appendTo( this.picker );
},
_buildMultiplePicker: function() {
var headerClass,
html = "",
currentDate = this.date,
months = this.date.months( this.options.numberOfMonths - 1 ),
i = 0;
for ( i; i < months.length; i++ ) {
this.date = months[ i ];
headerClass = months[ i ].first ? "ui-corner-left" :
months[ i ].last ? "ui-corner-right" : "";
html += "<div class='ui-datepicker-group'>" +
"<div class='ui-datepicker-header ui-widget-header ui-helper-clearfix " + headerClass + "'>";
if ( months[i].first ) {
html += this._buildPreviousLink();
}
if ( months[i].last ) {
html += this._buildNextLink();
}
html += this._buildTitlebar();
html += "</div>";
html += this._buildGrid();
html += "</div>";
}
html += "<div class='ui-datepicker-row-break'></div>";
html += this._buildButtons();
this.date = currentDate;
return html;
},
_buildHeader: function() {
return "<div class='ui-datepicker-header ui-widget-header ui-helper-clearfix ui-corner-all'>" +
this._buildPreviousLink() +
this._buildNextLink() +
this._buildTitlebar() +
"</div>";
},
_buildPreviousLink: function() {
var labels = Globalize.localize( "datepicker" );
return "<button class='ui-datepicker-prev ui-corner-all' " +
"title='" + labels.prevText + "'>" +
"<span class='ui-icon ui-icon-circle-triangle-w'>" +
labels.prevText +
"</span>" +
"</button>";
},
_buildNextLink: function() {
var labels = Globalize.localize( "datepicker" );
return "<button class='ui-datepicker-next ui-corner-all' " +
"title='" + labels.nextText + "'>" +
"<span class='ui-icon ui-icon-circle-triangle-e'>" +
labels.nextText +
"</span>" +
"</button>";
},
_buildTitlebar: function() {
var labels = Globalize.localize( "datepicker" );
return "<div role='header' id='" + this.id + "-title'>" +
"<div id='" + this.id + "-month-lbl' class='ui-datepicker-title'>" +
this._buildTitle() +
"</div>" +
"<span class='ui-helper-hidden-accessible'>, " + labels.datePickerRole + "</span>" +
"</div>";
},
_buildTitle: function() {
return "<span class='ui-datepicker-month'>" +
this.date.monthName() +
"</span> " +
"<span class='ui-datepicker-year'>" +
this.date.year() +
"</span>";
},
_buildGrid: function() {
return "<table class='ui-datepicker-calendar' role='grid' aria-readonly='true' " +
"aria-labelledby='" + this.id + "-month-lbl' tabindex='0' aria-activedescendant='" + this.id + "-" + this.date.day() + "'>" +
this._buildGridHeading() +
this._buildGridBody() +
"</table>";
},
_buildGridHeading: function() {
var cells = "",
i = 0,
labels = Globalize.localize( "datepicker" );
if ( this.options.showWeek ) {
cells += "<th>" + labels.weekHeader + "</th>";
}
for ( i; i < this.date.weekdays().length; i++ ) {
cells += this._buildGridHeaderCell( this.date.weekdays()[i] );
}
return "<thead role='presentation'>" +
"<tr role='row'>" + cells + "</tr>" +
"</thead>";
},
_buildGridHeaderCell: function( day ) {
return "<th role='columnheader' abbr='" + day.fullname + "' aria-label='" + day.fullname + "'>" +
"<span title='" + day.fullname + "'>" +
day.shortname +
"</span>" +
"</th>";
},
_buildGridBody: function() {
var rows = "",
i = 0;
for ( i; i < this.date.days().length; i++ ) {
rows += this._buildWeekRow( this.date.days()[i] );
}
return "<tbody role='presentation'>" + rows + "</tbody>";
},
_buildWeekRow: function( week ) {
var cells = "",
i = 0;
if ( this.options.showWeek ) {
cells += "<td>" + week.number + "</td>";
}
for ( i; i < week.days.length; i++ ) {
cells += this._buildDayCell( week.days[i] );
}
return "<tr role='row'>" + cells + "</tr>";
},
_buildDayCell: function( day ) {
var contents = "",
idAttribute = day.render ? ( "id=" + this.id + "-" + day.date ) : "",
ariaSelectedAttribute = "aria-selected=" + ( day.current ? "true" : "false" ),
ariaDisabledAttribute = day.selectable ? "" : "aria-disabled=true";
if ( day.render ) {
if ( day.selectable ) {
contents = this._buildDayLink( day );
} else {
contents = this._buildDayDisplay( day );
}
}
return "<td role='gridcell' " + idAttribute + " " + ariaSelectedAttribute + " " + ariaDisabledAttribute + ">" +
contents +
"</td>";
},
_buildDayLink: function( day ) {
var link,
classes = [ "ui-state-default" ],
labels = Globalize.localize( "datepicker" );
if ( day.date === this.date.day() ) {
classes.push( "ui-state-focus" );
}
if ( day.current ) {
classes.push( "ui-state-active" );
}
if ( day.today ) {
classes.push( "ui-state-highlight" );
}
if ( day.extraClasses ) {
classes.push( day.extraClasses.split( " " ) );
}
link = "<a href='#' tabindex='-1' data-timestamp='" + day.timestamp + "' class='" + classes.join( " " ) + "'>" +
day.date + "</a>";
if ( day.today ) {
link += "<span class='ui-helper-hidden-accessible'>, " + labels.currentText + "</span>";
}
return link;
},
_buildDayDisplay: function( day ) {
var classes = [];
if ( day.current ) {
classes.push( "ui-state-active" );
}
if ( day.today ) {
classes.push( "ui-state-highlight" );
}
if ( day.extraClasses ) {
classes.push( day.extraClasses.split( " " ) );
}
return "<span class='" + classes.join( "" ) + "'>" +
day.date + "</span>";
},
_buildButtons: function() {
var labels = Globalize.localize( "datepicker" );
return "<div class='ui-datepicker-buttonpane ui-widget-content'>" +
"<button class='ui-datepicker-current'>" + labels.currentText + "</button>" +
"<button class='ui-datepicker-close'>" + labels.closeText + "</button>" +
"</div>";
},
_focusTrigger: function() {
suppressExpandOnFocus = true;
this.element.focus();
},
// Refreshing the entire datepicker during interaction confuses screen readers, specifically
// because the grid heading is marked up as a live region and will often not update if it's
// destroyed and recreated instead of just having its text change. Additionally, interacting
// with the prev and next links would cause loss of focus issues because the links being
// interacted with will disappear while focused.
refresh: function() {
//determine which day gridcell to focus after refresh
//TODO: Prevent disabled cells from being focused
this.date.refresh();
if ( this.options.numberOfMonths === 1 ) {
this.grid = $( this._buildGrid() );
$( ".ui-datepicker-title", this.picker ).html( this._buildTitle() );
$( ".ui-datepicker-calendar", this.picker ).replaceWith( this.grid );
} else {
this._refreshMultiplePicker();
}
},
_refreshMultiplePicker: function() {
for ( var i = 0; i < this.options.numberOfMonths; i++ ) {
$( ".ui-datepicker-title", this.picker ).eq( i ).html( this._buildTitle() );
$( ".ui-datepicker-calendar", this.picker ).eq( i ).html( this._buildGrid() );
this.date.adjust( "M", 1 );
}
this.date.adjust( "M", -this.options.numberOfMonths );
},
open: function( event ) {
if ( this.inline || this.isOpen ) {
return;
}
if ( this._trigger( "beforeOpen", event ) === false ) {
return;
}
// TODO explain this
this.date = $.date( this.element.val(), this.options.dateFormat );
this.date.eachDay = this.options.eachDay;
this.date.select();
this.refresh();
var position = $.extend( {}, {
of: this.element
}, this.options.position );
this.picker
.attr( "aria-hidden", "false" )
.attr( "aria-expanded", "true" )
.show()
.position( position )
.hide();
this._show( this.picker, this.options.show );
// take trigger out of tab order to allow shift-tab to skip trigger
// TODO does this really make sense? related bug: tab-shift moves focus to last element on page
this.element.attr( "tabindex", -1 );
this.isOpen = true;
this._trigger( "open", event );
},
close: function( event ) {
if ( this.inline ) {
return;
}
this._setHiddenPicker();
this._hide( this.picker, this.options.hide );
this.element.attr( "tabindex" , 0 );
this.isOpen = false;
this._trigger( "close", event );
},
_setHiddenPicker: function() {
this.picker
.attr( "aria-hidden", "true" )
.attr( "aria-expanded", "false" );
},
select: function( event, time ) {
this.date.setTime( time ).select();
this.refresh();
if ( !this.inline ) {
this.element.val( this.date.format() );
this.close();
this._focusTrigger();
}
this._trigger( "select", event, {
// TODO replace with value option to initialise and read
date: this.date.format()
});
},
_destroy: function() {
if ( !this.inline ) {
this.picker.remove();
this.element
.removeAttr( "aria-haspopup" )
.removeAttr( "aria-owns" );
}
},
widget: function() {
return this.picker;
}
});
}));