Procházet zdrojové kódy

Merge branch 'master' into weeknr-in-daycell

Peter Nowee před 9 roky
rodič
revize
10147ac625
55 změnil soubory, kde provedl 961 přidání a 440 odebrání
  1. 4 1
      .gitignore
  2. 31 0
      CHANGELOG.md
  3. 1 1
      CONTRIBUTING.md
  4. 3 2
      Gruntfile.js
  5. 4 7
      bower.json
  6. 5 5
      build/jscs.conf.js
  7. 3 3
      build/jshint.conf.js
  8. 1 1
      build/karma.conf.js
  9. 16 16
      demos/agenda-views.html
  10. 14 14
      demos/background-events.html
  11. 16 16
      demos/basic-views.html
  12. 16 16
      demos/default.html
  13. 1 1
      demos/gcal.html
  14. 1 1
      demos/json.html
  15. 15 15
      demos/json/events.json
  16. 16 16
      demos/languages.html
  17. 16 16
      demos/selectable.html
  18. 16 16
      demos/theme.html
  19. 1 1
      demos/timezones.html
  20. 1 0
      lumbar.json
  21. 18 20
      package.json
  22. 1 10
      src/Calendar.js
  23. 8 0
      src/EventManager.js
  24. 3 3
      src/agenda/agenda.css
  25. 32 10
      src/common/DragListener.js
  26. 37 42
      src/common/EmitterMixin.js
  27. 69 32
      src/common/Grid.events.js
  28. 49 31
      src/common/Grid.js
  29. 3 2
      src/common/HitDragListener.js
  30. 27 0
      src/common/MouseIgnorerMixin.js
  31. 5 13
      src/common/View.js
  32. 18 29
      src/common/common.css
  33. 2 2
      src/gcal/gcal.js
  34. 1 4
      src/main.js
  35. 25 1
      src/util.js
  36. 46 0
      tests/automated/dayClick.js
  37. 1 0
      tests/automated/destroy.js
  38. 124 0
      tests/automated/emitter.js
  39. 5 11
      tests/automated/event-dnd.js
  40. 1 1
      tests/automated/event-feed-param.js
  41. 12 6
      tests/automated/event-resize.js
  42. 0 1
      tests/automated/eventClick.js
  43. 1 1
      tests/automated/eventLimit-popover.js
  44. 47 57
      tests/automated/events-gcal.js
  45. 1 1
      tests/automated/events-json-feed.js
  46. 2 0
      tests/automated/lang.js
  47. 1 1
      tests/automated/removeEventSource.js
  48. 3 6
      tests/automated/select-callback.js
  49. 2 2
      tests/automated/timezone.js
  50. 1 0
      tests/lib/dnd-resize-utils.js
  51. 2 4
      tests/lib/jasmine-ext.js
  52. 4 2
      tests/lib/simulate.js
  53. 57 0
      tests/manual_gh_3152.html
  54. 95 0
      tests/manual_gh_3160.html
  55. 77 0
      tests/touch-firing.html

+ 4 - 1
.gitignore

@@ -5,4 +5,7 @@ dist
 node_modules
 
 # bower components
-lib/*/*
+lib/*/*
+
+# ignore NPM logs
+npm-debug.log

+ 31 - 0
CHANGELOG.md

@@ -1,4 +1,35 @@
 
+v2.7.3 (2016-06-02)
+-------------------
+
+internal enhancements that plugins can benefit from:
+- EventEmitter not correctly working with stopListeningTo
+- normalizeEvent hook for manipulating event data
+
+
+v2.7.2 (2016-05-20)
+-------------------
+
+- fixed desktops/laptops with touch support not accepting mouse events for
+  dayClick/dragging/resizing (#3154, #3149)
+- fixed dayClick incorrectly triggered on touch scroll (#3152)
+- fixed touch event dragging wrongfully beginning upon scrolling document (#3160)
+- fixed minified JS still contained comments
+- UI change: mouse users must hover over an event to reveal its resizers
+
+
+v2.7.1 (2016-05-01)
+-------------------
+
+- dayClick not firing on touch devices (#3138)
+- icons for prev/next not working in MS Edge (#2852)
+- fix bad languages troubles with firewalls (#3133, #3132)
+- update all dev dependencies (#3145, #3010, #2901, #251)
+- git-ignore npm debug logs (#3011)
+- misc automated test updates (#3139, #3147)
+- Google Calendar htmlLink not always defined (#2844)
+
+
 v2.7.0 (2016-04-23)
 -------------------
 

+ 1 - 1
CONTRIBUTING.md

@@ -41,7 +41,7 @@ Also, you will need the [grunt-cli][grunt-cli] and [bower][bower] packages insta
 
 Then, clone FullCalendar's git repo:
 
-	git clone git://github.com/arshaw/fullcalendar.git
+	git clone git://github.com/fullcalendar/fullcalendar.git
 
 Enter the directory and install FullCalendar's development dependencies:
 

+ 3 - 2
Gruntfile.js

@@ -12,7 +12,7 @@ module.exports = function(grunt) {
 	grunt.loadNpmTasks('grunt-contrib-clean');
 	grunt.loadNpmTasks('grunt-contrib-jshint');
 	grunt.loadNpmTasks('grunt-contrib-cssmin');
-	grunt.loadNpmTasks('grunt-jscs-checker');
+	grunt.loadNpmTasks('grunt-jscs');
 	grunt.loadNpmTasks('grunt-shell');
 	grunt.loadNpmTasks('grunt-karma');
 	grunt.loadNpmTasks('grunt-bump');
@@ -97,7 +97,8 @@ module.exports = function(grunt) {
 	// create minified versions of JS
 	config.uglify.modules = {
 		options: {
-			preserveComments: 'some' // keep comments starting with /*!
+			preserveComments: /(?:^!|@(?:license|preserve|cc_on))/ // keep certain comments
+				// https://github.com/gruntjs/grunt-contrib-uglify/issues/366#issuecomment-157208530
 		},
 		expand: true,
 		src: 'dist/fullcalendar.js', // only do it for fullcalendar.js

+ 4 - 7
bower.json

@@ -15,16 +15,13 @@
     "type": "git",
     "url": "https://github.com/fullcalendar/fullcalendar.git"
   },
-  "license": {
-    "type": "MIT",
-    "url": "https://github.com/fullcalendar/fullcalendar/blob/master/LICENSE.txt"
-  },
+  "license": "MIT",
   "author": {
     "name": "Adam Shaw",
     "email": "[email protected]",
     "url": "http://arshaw.com/"
   },
-  "copyright": "2015 Adam Shaw",
+  "copyright": "2016 Adam Shaw",
   "dependencies": {
     "jquery": ">=1.7.1",
     "moment": ">=2.5.0"
@@ -32,11 +29,11 @@
   "devDependencies": {
     "jquery-ui": ">=1.11.1",
     "jquery-simulate": "~1.0.1",
-    "jquery-mockjax": "~1.5.4",
     "jasmine-jquery": "~2.0.3",
     "jasmine-fixture": "~1.2.0",
     "moment-timezone": "~0.2.1",
-    "bootstrap": "~3.2.0"
+    "bootstrap": "~3.2.0",
+    "jquery-mockjax": "~2.1.1"
   },
   "main": [
     "dist/fullcalendar.js",

+ 5 - 5
build/jscs.conf.js

@@ -4,16 +4,16 @@ module.exports = {
 		requireCurlyBraces: [ 'if', 'else', 'for', 'while', 'do', 'try', 'catch' ],
 		requireSpacesInFunctionExpression: { beforeOpeningCurlyBrace: true },
 		disallowSpacesInFunctionExpression: { beforeOpeningRoundBrace: true },
-		disallowSpacesInsideParentheses: true,
+		//disallowSpacesInsideParentheses: true, // can't handle `something( //`
 		requireSpacesInsideObjectBrackets: 'all',
 		disallowQuotedKeysInObjects: 'allButReserved',
 		disallowSpaceAfterObjectKeys: true,
 		requireCommaBeforeLineBreak: true,
 		requireOperatorBeforeLineBreak: [ '?', '+', '-', '/', '*', '=', '==', '===', '!=', '!==', '>', '>=', '<', '<=' ],
-		disallowLeftStickedOperators: [ '?' ],
-		requireRightStickedOperators: [ '!' ],
-		requireLeftStickedOperators: [ ',' ],
-		disallowRightStickedOperators: [ ':' ],
+		requireSpacesInConditionalExpression: true,
+		requireSpaceAfterComma: true,
+		disallowSpaceBeforeComma: true,
+		requireSpaceBeforeDestructuredValues: true,
 		disallowSpaceAfterPrefixUnaryOperators: [ '++', '--', '+', '-', '~', '!' ],
 		disallowSpaceBeforePostfixUnaryOperators: [ '++', '--' ],
 		disallowKeywords: [ 'with' ],

+ 3 - 3
build/jshint.conf.js

@@ -14,7 +14,7 @@ module.exports = {
 		es3: true,
 		bitwise: true,
 		curly: true,
-		forin: true,
+		//forin: true, // couldn't handle `for (k in o) if (!`
 		freeze: true,
 		immed: true,
 		noarg: true,
@@ -35,8 +35,8 @@ module.exports = {
 		options: {
 			// Built modules are ready to be checked for...
 			undef: true, // use of undeclared globals
-			unused: 'vars', // functions/variables (excluding function arguments) that are never used
-			latedef: 'nofunc' // variables that are referenced before their `var` statement
+			unused: 'vars' // functions/variables (excluding function arguments) that are never used
+			//latedef: 'nofunc' // variables that are referenced before their `var` statement // TODO: revisit
 		},
 		src: [
 			'dist/*.js',

+ 1 - 1
build/karma.conf.js

@@ -25,7 +25,7 @@ module.exports = function(config) {
 			'../lib/jquery-ui/jquery-ui.js',
 
 			'../lib/jquery-simulate/jquery.simulate.js',
-			'../lib/jquery-mockjax/jquery.mockjax.js',
+			'../lib/jquery-mockjax/dist/jquery.mockjax.js',
 			'../lib/jasmine-jquery/lib/jasmine-jquery.js',
 			'../lib/jasmine-fixture/dist/jasmine-fixture.js',
 

+ 16 - 16
demos/agenda-views.html

@@ -17,63 +17,63 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2016-01-01'
+					start: '2016-05-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2016-01-07',
-					end: '2016-01-10'
+					start: '2016-05-07',
+					end: '2016-05-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-09T16:00:00'
+					start: '2016-05-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-16T16:00:00'
+					start: '2016-05-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2016-01-11',
-					end: '2016-01-13'
+					start: '2016-05-11',
+					end: '2016-05-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T10:30:00',
-					end: '2016-01-12T12:30:00'
+					start: '2016-05-12T10:30:00',
+					end: '2016-05-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2016-01-12T12:00:00'
+					start: '2016-05-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T14:30:00'
+					start: '2016-05-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2016-01-12T17:30:00'
+					start: '2016-05-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2016-01-12T20:00:00'
+					start: '2016-05-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2016-01-13T07:00:00'
+					start: '2016-05-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2016-01-28'
+					start: '2016-05-28'
 				}
 			]
 		});

+ 14 - 14
demos/background-events.html

@@ -17,56 +17,56 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			businessHours: true, // display business hours
 			editable: true,
 			events: [
 				{
 					title: 'Business Lunch',
-					start: '2016-01-03T13:00:00',
+					start: '2016-05-03T13:00:00',
 					constraint: 'businessHours'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-13T11:00:00',
+					start: '2016-05-13T11:00:00',
 					constraint: 'availableForMeeting', // defined below
 					color: '#257e4a'
 				},
 				{
 					title: 'Conference',
-					start: '2016-01-18',
-					end: '2016-01-20'
+					start: '2016-05-18',
+					end: '2016-05-20'
 				},
 				{
 					title: 'Party',
-					start: '2016-01-29T20:00:00'
+					start: '2016-05-29T20:00:00'
 				},
 
 				// areas where "Meeting" must be dropped
 				{
 					id: 'availableForMeeting',
-					start: '2016-01-11T10:00:00',
-					end: '2016-01-11T16:00:00',
+					start: '2016-05-11T10:00:00',
+					end: '2016-05-11T16:00:00',
 					rendering: 'background'
 				},
 				{
 					id: 'availableForMeeting',
-					start: '2016-01-13T10:00:00',
-					end: '2016-01-13T16:00:00',
+					start: '2016-05-13T10:00:00',
+					end: '2016-05-13T16:00:00',
 					rendering: 'background'
 				},
 
 				// red areas where no events can be dropped
 				{
-					start: '2016-01-24',
-					end: '2016-01-28',
+					start: '2016-05-24',
+					end: '2016-05-28',
 					overlap: false,
 					rendering: 'background',
 					color: '#ff9f89'
 				},
 				{
-					start: '2016-01-06',
-					end: '2016-01-08',
+					start: '2016-05-06',
+					end: '2016-05-08',
 					overlap: false,
 					rendering: 'background',
 					color: '#ff9f89'

+ 16 - 16
demos/basic-views.html

@@ -17,63 +17,63 @@
 				center: 'title',
 				right: 'month,basicWeek,basicDay'
 			},
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2016-01-01'
+					start: '2016-05-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2016-01-07',
-					end: '2016-01-10'
+					start: '2016-05-07',
+					end: '2016-05-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-09T16:00:00'
+					start: '2016-05-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-16T16:00:00'
+					start: '2016-05-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2016-01-11',
-					end: '2016-01-13'
+					start: '2016-05-11',
+					end: '2016-05-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T10:30:00',
-					end: '2016-01-12T12:30:00'
+					start: '2016-05-12T10:30:00',
+					end: '2016-05-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2016-01-12T12:00:00'
+					start: '2016-05-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T14:30:00'
+					start: '2016-05-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2016-01-12T17:30:00'
+					start: '2016-05-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2016-01-12T20:00:00'
+					start: '2016-05-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2016-01-13T07:00:00'
+					start: '2016-05-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2016-01-28'
+					start: '2016-05-28'
 				}
 			]
 		});

+ 16 - 16
demos/default.html

@@ -12,63 +12,63 @@
 	$(document).ready(function() {
 
 		$('#calendar').fullCalendar({
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2016-01-01'
+					start: '2016-05-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2016-01-07',
-					end: '2016-01-10'
+					start: '2016-05-07',
+					end: '2016-05-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-09T16:00:00'
+					start: '2016-05-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-16T16:00:00'
+					start: '2016-05-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2016-01-11',
-					end: '2016-01-13'
+					start: '2016-05-11',
+					end: '2016-05-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T10:30:00',
-					end: '2016-01-12T12:30:00'
+					start: '2016-05-12T10:30:00',
+					end: '2016-05-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2016-01-12T12:00:00'
+					start: '2016-05-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T14:30:00'
+					start: '2016-05-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2016-01-12T17:30:00'
+					start: '2016-05-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2016-01-12T20:00:00'
+					start: '2016-05-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2016-01-13T07:00:00'
+					start: '2016-05-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2016-01-28'
+					start: '2016-05-28'
 				}
 			]
 		});

+ 1 - 1
demos/gcal.html

@@ -20,7 +20,7 @@
 			googleCalendarApiKey: 'AIzaSyDcnW6WejpTOCffshGDDb4neIrXVUA1EAE',
 		
 			// US Holidays
-			events: 'usa__en@holiday.calendar.google.com',
+			events: 'en.usa#[email protected].calendar.google.com',
 			
 			eventClick: function(event) {
 				// opens events in a popup window

+ 1 - 1
demos/json.html

@@ -17,7 +17,7 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: {

+ 15 - 15
demos/json/events.json

@@ -1,56 +1,56 @@
 [
   {
     "title": "All Day Event",
-    "start": "2016-01-01"
+    "start": "2016-05-01"
   },
   {
     "title": "Long Event",
-    "start": "2016-01-07",
-    "end": "2016-01-10"
+    "start": "2016-05-07",
+    "end": "2016-05-10"
   },
   {
     "id": "999",
     "title": "Repeating Event",
-    "start": "2016-01-09T16:00:00-05:00"
+    "start": "2016-05-09T16:00:00-05:00"
   },
   {
     "id": "999",
     "title": "Repeating Event",
-    "start": "2016-01-16T16:00:00-05:00"
+    "start": "2016-05-16T16:00:00-05:00"
   },
   {
     "title": "Conference",
-    "start": "2016-01-11",
-    "end": "2016-01-13"
+    "start": "2016-05-11",
+    "end": "2016-05-13"
   },
   {
     "title": "Meeting",
-    "start": "2016-01-12T10:30:00-05:00",
-    "end": "2016-01-12T12:30:00-05:00"
+    "start": "2016-05-12T10:30:00-05:00",
+    "end": "2016-05-12T12:30:00-05:00"
   },
   {
     "title": "Lunch",
-    "start": "2016-01-12T12:00:00-05:00"
+    "start": "2016-05-12T12:00:00-05:00"
   },
   {
     "title": "Meeting",
-    "start": "2016-01-12T14:30:00-05:00"
+    "start": "2016-05-12T14:30:00-05:00"
   },
   {
     "title": "Happy Hour",
-    "start": "2016-01-12T17:30:00-05:00"
+    "start": "2016-05-12T17:30:00-05:00"
   },
   {
     "title": "Dinner",
-    "start": "2016-01-12T20:00:00"
+    "start": "2016-05-12T20:00:00"
   },
   {
     "title": "Birthday Party",
-    "start": "2016-01-13T07:00:00-05:00"
+    "start": "2016-05-13T07:00:00-05:00"
   },
   {
     "title": "Click for Google",
     "url": "http://google.com/",
-    "start": "2016-01-28"
+    "start": "2016-05-28"
   }
 ]

+ 16 - 16
demos/languages.html

@@ -40,7 +40,7 @@
 					center: 'title',
 					right: 'month,agendaWeek,agendaDay'
 				},
-				defaultDate: '2016-01-12',
+				defaultDate: '2016-05-12',
 				lang: currentLangCode,
 				buttonIcons: false, // show the prev/next text
 				weekNumbers: true,
@@ -49,57 +49,57 @@
 				events: [
 					{
 						title: 'All Day Event',
-						start: '2016-01-01'
+						start: '2016-05-01'
 					},
 					{
 						title: 'Long Event',
-						start: '2016-01-07',
-						end: '2016-01-10'
+						start: '2016-05-07',
+						end: '2016-05-10'
 					},
 					{
 						id: 999,
 						title: 'Repeating Event',
-						start: '2016-01-09T16:00:00'
+						start: '2016-05-09T16:00:00'
 					},
 					{
 						id: 999,
 						title: 'Repeating Event',
-						start: '2016-01-16T16:00:00'
+						start: '2016-05-16T16:00:00'
 					},
 					{
 						title: 'Conference',
-						start: '2016-01-11',
-						end: '2016-01-13'
+						start: '2016-05-11',
+						end: '2016-05-13'
 					},
 					{
 						title: 'Meeting',
-						start: '2016-01-12T10:30:00',
-						end: '2016-01-12T12:30:00'
+						start: '2016-05-12T10:30:00',
+						end: '2016-05-12T12:30:00'
 					},
 					{
 						title: 'Lunch',
-						start: '2016-01-12T12:00:00'
+						start: '2016-05-12T12:00:00'
 					},
 					{
 						title: 'Meeting',
-						start: '2016-01-12T14:30:00'
+						start: '2016-05-12T14:30:00'
 					},
 					{
 						title: 'Happy Hour',
-						start: '2016-01-12T17:30:00'
+						start: '2016-05-12T17:30:00'
 					},
 					{
 						title: 'Dinner',
-						start: '2016-01-12T20:00:00'
+						start: '2016-05-12T20:00:00'
 					},
 					{
 						title: 'Birthday Party',
-						start: '2016-01-13T07:00:00'
+						start: '2016-05-13T07:00:00'
 					},
 					{
 						title: 'Click for Google',
 						url: 'http://google.com/',
-						start: '2016-01-28'
+						start: '2016-05-28'
 					}
 				]
 			});

+ 16 - 16
demos/selectable.html

@@ -17,7 +17,7 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			selectable: true,
 			selectHelper: true,
 			select: function(start, end) {
@@ -38,57 +38,57 @@
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2016-01-01'
+					start: '2016-05-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2016-01-07',
-					end: '2016-01-10'
+					start: '2016-05-07',
+					end: '2016-05-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-09T16:00:00'
+					start: '2016-05-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-16T16:00:00'
+					start: '2016-05-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2016-01-11',
-					end: '2016-01-13'
+					start: '2016-05-11',
+					end: '2016-05-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T10:30:00',
-					end: '2016-01-12T12:30:00'
+					start: '2016-05-12T10:30:00',
+					end: '2016-05-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2016-01-12T12:00:00'
+					start: '2016-05-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T14:30:00'
+					start: '2016-05-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2016-01-12T17:30:00'
+					start: '2016-05-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2016-01-12T20:00:00'
+					start: '2016-05-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2016-01-13T07:00:00'
+					start: '2016-05-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2016-01-28'
+					start: '2016-05-28'
 				}
 			]
 		});

+ 16 - 16
demos/theme.html

@@ -19,63 +19,63 @@
 				center: 'title',
 				right: 'month,agendaWeek,agendaDay'
 			},
-			defaultDate: '2016-01-12',
+			defaultDate: '2016-05-12',
 			editable: true,
 			eventLimit: true, // allow "more" link when too many events
 			events: [
 				{
 					title: 'All Day Event',
-					start: '2016-01-01'
+					start: '2016-05-01'
 				},
 				{
 					title: 'Long Event',
-					start: '2016-01-07',
-					end: '2016-01-10'
+					start: '2016-05-07',
+					end: '2016-05-10'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-09T16:00:00'
+					start: '2016-05-09T16:00:00'
 				},
 				{
 					id: 999,
 					title: 'Repeating Event',
-					start: '2016-01-16T16:00:00'
+					start: '2016-05-16T16:00:00'
 				},
 				{
 					title: 'Conference',
-					start: '2016-01-11',
-					end: '2016-01-13'
+					start: '2016-05-11',
+					end: '2016-05-13'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T10:30:00',
-					end: '2016-01-12T12:30:00'
+					start: '2016-05-12T10:30:00',
+					end: '2016-05-12T12:30:00'
 				},
 				{
 					title: 'Lunch',
-					start: '2016-01-12T12:00:00'
+					start: '2016-05-12T12:00:00'
 				},
 				{
 					title: 'Meeting',
-					start: '2016-01-12T14:30:00'
+					start: '2016-05-12T14:30:00'
 				},
 				{
 					title: 'Happy Hour',
-					start: '2016-01-12T17:30:00'
+					start: '2016-05-12T17:30:00'
 				},
 				{
 					title: 'Dinner',
-					start: '2016-01-12T20:00:00'
+					start: '2016-05-12T20:00:00'
 				},
 				{
 					title: 'Birthday Party',
-					start: '2016-01-13T07:00:00'
+					start: '2016-05-13T07:00:00'
 				},
 				{
 					title: 'Click for Google',
 					url: 'http://google.com/',
-					start: '2016-01-28'
+					start: '2016-05-28'
 				}
 			]
 		});

+ 1 - 1
demos/timezones.html

@@ -37,7 +37,7 @@
 					center: 'title',
 					right: 'month,agendaWeek,agendaDay'
 				},
-				defaultDate: '2016-01-12',
+				defaultDate: '2016-05-12',
 				timezone: currentTimezone,
 				editable: true,
 				selectable: true,

+ 1 - 0
lumbar.json

@@ -20,6 +20,7 @@
         "src/common/Class.js",
         "src/common/EmitterMixin.js",
         "src/common/ListenerMixin.js",
+        "src/common/MouseIgnorerMixin.js",
         "src/common/Popover.js",
         "src/common/CoordCache.js",
         "src/common/DragListener.js",

+ 18 - 20
package.json

@@ -15,37 +15,35 @@
     "type": "git",
     "url": "https://github.com/fullcalendar/fullcalendar.git"
   },
-  "license": {
-    "type": "MIT",
-    "url": "https://github.com/fullcalendar/fullcalendar/blob/master/LICENSE.txt"
-  },
+  "license": "MIT",
   "author": {
     "name": "Adam Shaw",
     "email": "[email protected]",
     "url": "http://arshaw.com/"
   },
-  "copyright": "2015 Adam Shaw",
+  "copyright": "2016 Adam Shaw",
   "dependencies": {
     "jquery": ">=1.7.1",
     "moment": ">=2.5.0"
   },
   "devDependencies": {
-    "underscore": "^1.4.4",
-    "grunt": "^0.4.5",
-    "grunt-contrib-concat": "^0.1.3",
-    "grunt-contrib-uglify": "^0.9.1",
-    "grunt-contrib-copy": "^0.4.1",
-    "grunt-contrib-compress": "^0.4.10",
-    "grunt-contrib-clean": "^0.4.1",
-    "grunt-contrib-jshint": "^0.8.0",
-    "grunt-contrib-cssmin": "^0.10.0",
-    "grunt-jscs-checker": "^0.4.4",
-    "grunt-shell": "^0.7.0",
-    "grunt-karma": "^0.8.3",
-    "grunt-bump": "0.0.14",
-    "lumbar": "^2.6.2",
+    "grunt": "^1.0.1",
+    "grunt-bump": "^0.8.0",
+    "grunt-contrib-clean": "^1.0.0",
+    "grunt-contrib-compress": "^1.2.0",
+    "grunt-contrib-concat": "^1.0.1",
+    "grunt-contrib-copy": "^1.0.0",
+    "grunt-contrib-cssmin": "^1.0.1",
+    "grunt-contrib-jshint": "^1.0.0",
+    "grunt-contrib-uglify": "^1.0.1",
+    "grunt-jscs": "^2.8.0",
+    "grunt-karma": "^0.12.2",
+    "grunt-shell": "^1.3.0",
+    "karma": "^0.13.22",
     "karma-jasmine": "^0.2.2",
-    "karma-phantomjs-launcher": "^0.1.4"
+    "karma-phantomjs-launcher": "^0.1.4",
+    "lumbar": "^2.6.2",
+    "underscore": "^1.4.4"
   },
   "main": "dist/fullcalendar.js",
   "files": [

+ 1 - 10
src/Calendar.js

@@ -9,7 +9,6 @@ var Calendar = FC.Calendar = Class.extend({
 	view: null, // current View object
 	header: null,
 	loadingLevel: 0, // number of simultaneous loading tasks
-	isTouch: false,
 
 
 	// a lot of this class' OOP logic is scoped within this constructor function,
@@ -56,10 +55,6 @@ var Calendar = FC.Calendar = Class.extend({
 		]);
 		populateInstanceComputableOptions(this.options);
 
-		this.isTouch = this.options.isTouch != null ?
-			this.options.isTouch :
-			FC.isTouch;
-
 		this.viewSpecCache = {}; // somewhat unrelated
 	},
 
@@ -558,10 +553,6 @@ function Calendar_constructor(element, overrides) {
 		tm = options.theme ? 'ui' : 'fc';
 		element.addClass('fc');
 
-		element.addClass(
-			t.isTouch ? 'fc-touch' : 'fc-cursor'
-		);
-
 		if (options.isRTL) {
 			element.addClass('fc-rtl');
 		}
@@ -604,7 +595,7 @@ function Calendar_constructor(element, overrides) {
 
 		header.removeElement();
 		content.remove();
-		element.removeClass('fc fc-touch fc-cursor fc-ltr fc-rtl fc-unthemed ui-widget');
+		element.removeClass('fc fc-ltr fc-rtl fc-unthemed ui-widget');
 
 		if (windowResizeProxy) {
 			$(window).unbind('resize', windowResizeProxy);

+ 8 - 0
src/EventManager.js

@@ -517,6 +517,8 @@ function EventManager(options) { // assumed to be a calendar
 			assignDatesToEvent(start, end, allDay, out);
 		}
 
+		t.normalizeEvent(out); // hook for external use. a prototype method
+
 		return out;
 	}
 
@@ -1049,6 +1051,12 @@ function EventManager(options) { // assumed to be a calendar
 }
 
 
+// hook for external libs to manipulate event properties upon creation.
+// should manipulate the event in-place.
+Calendar.prototype.normalizeEvent = function(event) {
+};
+
+
 // Returns a list of events that the given event should be compared against when being considered for a move to
 // the specified span. Attached to the Calendar's prototype because EventManager is a mixin for a Calendar.
 Calendar.prototype.getPeerEvents = function(span, event) {

+ 3 - 3
src/agenda/agenda.css

@@ -256,7 +256,7 @@ be a descendant of the grid when it is being dragged.
 
 /* resizer (cursor device) */
 
-.fc-cursor .fc-time-grid-event .fc-resizer {
+.fc-time-grid-event.fc-allow-mouse-resize .fc-resizer {
 	left: 0;
 	right: 0;
 	bottom: 0;
@@ -269,13 +269,13 @@ be a descendant of the grid when it is being dragged.
 	cursor: s-resize;
 }
 
-.fc-cursor .fc-time-grid-event .fc-resizer:after {
+.fc-time-grid-event.fc-allow-mouse-resize .fc-resizer:after {
 	content: "=";
 }
 
 /* resizer (touch device) */
 
-.fc-touch .fc-time-grid-event .fc-resizer {
+.fc-time-grid-event.fc-selected .fc-resizer {
 	/* 10x10 dot */
 	border-radius: 5px;
 	border-width: 1px;

+ 32 - 10
src/common/DragListener.js

@@ -3,7 +3,7 @@
 ----------------------------------------------------------------------------------------------------------------------*/
 // TODO: use Emitter
 
-var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
+var DragListener = FC.DragListener = Class.extend(ListenerMixin, MouseIgnorerMixin, {
 
 	options: null,
 
@@ -15,6 +15,8 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 	originX: null,
 	originY: null,
 
+	// the wrapping element that scrolls, or MIGHT scroll if there's overflow.
+	// TODO: do this for wrappers that have overflow:hidden as well.
 	scrollEl: null,
 
 	isInteracting: false,
@@ -27,9 +29,13 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 	delayTimeoutId: null,
 	minDistance: null,
 
+	handleTouchScrollProxy: null, // calls handleTouchScroll, always bound to `this`
+
 
 	constructor: function(options) {
 		this.options = options || {};
+		this.handleTouchScrollProxy = proxy(this, 'handleTouchScroll');
+		this.initMouseIgnoring(500);
 	},
 
 
@@ -41,7 +47,10 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 		var isTouch = getEvIsTouch(ev);
 
 		if (ev.type === 'mousedown') {
-			if (!isPrimaryMouseButton(ev)) {
+			if (this.isIgnoringMouse) {
+				return;
+			}
+			else if (!isPrimaryMouseButton(ev)) {
 				return;
 			}
 			else {
@@ -83,7 +92,7 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 	},
 
 
-	endInteraction: function(ev) {
+	endInteraction: function(ev, isCancelled) {
 		if (this.isInteracting) {
 			this.endDrag(ev);
 
@@ -96,13 +105,20 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 			this.unbindHandlers();
 
 			this.isInteracting = false;
-			this.handleInteractionEnd(ev);
+			this.handleInteractionEnd(ev, isCancelled);
+
+			// a touchstart+touchend on the same element will result in the following addition simulated events:
+			// mouseover + mouseout + click
+			// let's ignore these bogus events
+			if (this.isTouch) {
+				this.tempIgnoreMouse();
+			}
 		}
 	},
 
 
-	handleInteractionEnd: function(ev) {
-		this.trigger('interactionEnd', ev);
+	handleInteractionEnd: function(ev, isCancelled) {
+		this.trigger('interactionEnd', ev, isCancelled || false);
 	},
 
 
@@ -129,12 +145,16 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 						touchStartIgnores--; // and we don't want this to fire immediately, so ignore.
 					}
 					else {
-						_this.endInteraction(ev);
+						_this.endInteraction(ev, true); // isCancelled=true
 					}
 				}
 			});
 
-			if (this.scrollEl) {
+			// listen to ALL scroll actions on the page
+			if (
+				!bindAnyScroll(this.handleTouchScrollProxy) && // hopefully this works and short-circuits the rest
+				this.scrollEl // otherwise, attach a single handler to this
+			) {
 				this.listenTo(this.scrollEl, 'scroll', this.handleTouchScroll);
 			}
 		}
@@ -155,8 +175,10 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 	unbindHandlers: function() {
 		this.stopListeningTo($(document));
 
+		// unbind scroll listening
+		unbindAnyScroll(this.handleTouchScrollProxy);
 		if (this.scrollEl) {
-			this.stopListeningTo(this.scrollEl);
+			this.stopListeningTo(this.scrollEl, 'scroll');
 		}
 	},
 
@@ -289,7 +311,7 @@ var DragListener = FC.DragListener = Class.extend(ListenerMixin, {
 		// if the drag is being initiated by touch, but a scroll happens before
 		// the drag-initiating delay is over, cancel the drag
 		if (!this.isDragging) {
-			this.endInteraction(ev);
+			this.endInteraction(ev, true); // isCancelled=true
 		}
 	},
 

+ 37 - 42
src/common/EmitterMixin.js

@@ -1,66 +1,61 @@
 
 var EmitterMixin = FC.EmitterMixin = {
 
-	callbackHash: null,
-
+	// jQuery-ification via $(this) allows a non-DOM object to have
+	// the same event handling capabilities (including namespaces).
+
+
+	on: function(types, handler) {
+
+		// handlers are always called with an "event" object as their first param.
+		// sneak the `this` context and arguments into the extra parameter object
+		// and forward them on to the original handler.
+		var intercept = function(ev, extra) {
+			return handler.apply(
+				extra.context || this,
+				extra.args || []
+			);
+		};
+
+		// mimick jQuery's internal "proxy" system (risky, I know)
+		// causing all functions with the same .guid to appear to be the same.
+		// https://github.com/jquery/jquery/blob/2.2.4/src/core.js#L448
+		// this is needed for calling .off with the original non-intercept handler.
+		if (!handler.guid) {
+			handler.guid = $.guid++;
+		}
+		intercept.guid = handler.guid;
 
-	on: function(name, callback) {
-		this.loopCallbacks(name, 'add', [ callback ]);
+		$(this).on(types, intercept);
 
 		return this; // for chaining
 	},
 
 
-	off: function(name, callback) {
-		this.loopCallbacks(name, 'remove', [ callback ]);
+	off: function(types, handler) {
+		$(this).off(types, handler);
 
 		return this; // for chaining
 	},
 
 
-	trigger: function(name) { // args...
-		var args = Array.prototype.slice.call(arguments, 1);
-
-		this.triggerWith(name, this, args);
-
-		return this; // for chaining
-	},
-
+	trigger: function(types) {
+		var args = Array.prototype.slice.call(arguments, 1); // arguments after the first
 
-	triggerWith: function(name, context, args) {
-		this.loopCallbacks(name, 'fireWith', [ context, args ]);
+		// pass in "extra" info to the intercept
+		$(this).triggerHandler(types, { args: args });
 
 		return this; // for chaining
 	},
 
 
-	/*
-	Given an event name string with possible namespaces,
-	call the given methodName on all the internal Callback object with the given arguments.
-	*/
-	loopCallbacks: function(name, methodName, args) {
-		var parts = name.split('.'); // "click.namespace" -> [ "click", "namespace" ]
-		var i, part;
-		var callbackObj;
-
-		for (i = 0; i < parts.length; i++) {
-			part = parts[i];
-			if (part) { // in case no event name like "click"
-				callbackObj = this.ensureCallbackObj((i ? '.' : '') + part); // put periods in front of namespaces
-				callbackObj[methodName].apply(callbackObj, args);
-			}
-		}
-	},
+	triggerWith: function(types, context, args) {
 
+		// `triggerHandler` is less reliant on the DOM compared to `trigger`.
+		// pass in "extra" info to the intercept.
+		$(this).triggerHandler(types, { context: context, args: args });
 
-	ensureCallbackObj: function(name) {
-		if (!this.callbackHash) {
-			this.callbackHash = {};
-		}
-		if (!this.callbackHash[name]) {
-			this.callbackHash[name] = $.Callbacks();
-		}
-		return this.callbackHash[name];
+		return this; // for chaining
 	}
 
-};
+};

+ 69 - 32
src/common/Grid.events.js

@@ -175,15 +175,11 @@ Grid.mixin({
 
 	// Attaches event-element-related handlers to the container element and leverage bubbling
 	bindSegHandlers: function() {
-		if (this.view.calendar.isTouch) {
-			this.bindSegHandler('touchstart', this.handleSegTouchStart);
-		}
-		else {
-			this.bindSegHandler('mouseenter', this.handleSegMouseover);
-			this.bindSegHandler('mouseleave', this.handleSegMouseout);
-			this.bindSegHandler('mousedown', this.handleSegMousedown);
-		}
-
+		this.bindSegHandler('touchstart', this.handleSegTouchStart);
+		this.bindSegHandler('touchend', this.handleSegTouchEnd);
+		this.bindSegHandler('mouseenter', this.handleSegMouseover);
+		this.bindSegHandler('mouseleave', this.handleSegMouseout);
+		this.bindSegHandler('mousedown', this.handleSegMousedown);
 		this.bindSegHandler('click', this.handleSegClick);
 	},
 
@@ -211,8 +207,12 @@ Grid.mixin({
 
 	// Updates internal state and triggers handlers for when an event element is moused over
 	handleSegMouseover: function(seg, ev) {
-		if (!this.mousedOverSeg) {
+		if (
+			!this.isIgnoringMouse &&
+			!this.mousedOverSeg
+		) {
 			this.mousedOverSeg = seg;
+			seg.el.addClass('fc-allow-mouse-resize');
 			this.view.trigger('eventMouseover', seg.el[0], seg.event, ev);
 		}
 	},
@@ -226,11 +226,24 @@ Grid.mixin({
 		if (this.mousedOverSeg) {
 			seg = seg || this.mousedOverSeg; // if given no args, use the currently moused-over segment
 			this.mousedOverSeg = null;
+			seg.el.removeClass('fc-allow-mouse-resize');
 			this.view.trigger('eventMouseout', seg.el[0], seg.event, ev);
 		}
 	},
 
 
+	handleSegMousedown: function(seg, ev) {
+		var isResizing = this.startSegResize(seg, ev, { distance: 5 });
+
+		if (!isResizing && this.view.isEventDraggable(seg.event)) {
+			this.buildSegDragListener(seg)
+				.startInteraction(ev, {
+					distance: 5
+				});
+		}
+	},
+
+
 	handleSegTouchStart: function(seg, ev) {
 		var view = this.view;
 		var event = seg.event;
@@ -246,37 +259,25 @@ Grid.mixin({
 		}
 
 		if (!isResizing && (isDraggable || isResizable)) { // allowed to be selected?
-			this.clearDragListeners();
 
 			dragListener = isDraggable ?
 				this.buildSegDragListener(seg) :
-				new DragListener(); // seg isn't draggable, but let's use a generic DragListener
-				                    // simply for the delay, so it can be selected.
+				this.buildSegSelectListener(seg); // seg isn't draggable, but still needs to be selected
 
-			dragListener._dragStart = function() { // TODO: better way of binding
-				// if not previously selected, will fire after a delay. then, select the event
-				if (!isSelected) {
-					view.selectEvent(event);
-				}
-			};
-
-			dragListener.startInteraction(ev, {
+			dragListener.startInteraction(ev, { // won't start if already started
 				delay: isSelected ? 0 : this.view.opt('longPressDelay') // do delay if not already selected
 			});
 		}
-	},
 
+		// a long tap simulates a mouseover. ignore this bogus mouseover.
+		this.tempIgnoreMouse();
+	},
 
-	handleSegMousedown: function(seg, ev) {
-		var isResizing = this.startSegResize(seg, ev, { distance: 5 });
 
-		if (!isResizing && this.view.isEventDraggable(seg.event)) {
-			this.clearDragListeners();
-			this.buildSegDragListener(seg)
-				.startInteraction(ev, {
-					distance: 5
-				});
-		}
+	handleSegTouchEnd: function(seg, ev) {
+		// touchstart+touchend = click, which simulates a mouseover.
+		// ignore this bogus mouseover.
+		this.tempIgnoreMouse();
 	},
 
 
@@ -285,7 +286,6 @@ Grid.mixin({
 	// `dragOptions` are optional.
 	startSegResize: function(seg, ev, dragOptions) {
 		if ($(ev.target).is('.fc-resizer')) {
-			this.clearDragListeners();
 			this.buildSegResizeListener(seg, $(ev.target).is('.fc-start-resizer'))
 				.startInteraction(ev, dragOptions);
 			return true;
@@ -301,6 +301,7 @@ Grid.mixin({
 
 	// Builds a listener that will track user-dragging on an event segment.
 	// Generic enough to work with any type of Grid.
+	// Has side effect of setting/unsetting `segDragListener`
 	buildSegDragListener: function(seg) {
 		var _this = this;
 		var view = this.view;
@@ -311,6 +312,10 @@ Grid.mixin({
 		var mouseFollower; // A clone of the original element that will move with the mouse
 		var dropLocation; // zoned event date properties
 
+		if (this.segDragListener) {
+			return this.segDragListener;
+		}
+
 		// Tracks mouse movement over the *view's* coordinate map. Allows dragging and dropping between subcomponents
 		// of the view.
 		var dragListener = this.segDragListener = new HitDragListener(view, {
@@ -330,6 +335,10 @@ Grid.mixin({
 				mouseFollower.start(ev);
 			},
 			dragStart: function(ev) {
+				if (dragListener.isTouch && !view.isEventSelected(event)) {
+					// if not previously selected, will fire after a delay. then, select the event
+					view.selectEvent(event);
+				}
 				isDragging = true;
 				_this.handleSegMouseout(seg, ev); // ensure a mouseout on the manipulated event has been reported
 				_this.segDragStart(seg, ev);
@@ -401,6 +410,34 @@ Grid.mixin({
 	},
 
 
+	// seg isn't draggable, but let's use a generic DragListener
+	// simply for the delay, so it can be selected.
+	// Has side effect of setting/unsetting `segDragListener`
+	buildSegSelectListener: function(seg) {
+		var _this = this;
+		var view = this.view;
+		var event = seg.event;
+
+		if (this.segDragListener) {
+			return this.segDragListener;
+		}
+
+		var dragListener = this.segDragListener = new DragListener({
+			dragStart: function(ev) {
+				if (dragListener.isTouch && !view.isEventSelected(event)) {
+					// if not previously selected, will fire after a delay. then, select the event
+					view.selectEvent(event);
+				}
+			},
+			interactionEnd: function(ev) {
+				_this.segDragListener = null;
+			}
+		});
+
+		return dragListener;
+	},
+
+
 	// Called before event segment dragging starts
 	segDragStart: function(seg, ev) {
 		this.isDraggingSeg = true;

+ 49 - 31
src/common/Grid.js

@@ -2,7 +2,7 @@
 /* An abstract class comprised of a "grid" of areas that each represent a specific datetime
 ----------------------------------------------------------------------------------------------------------------------*/
 
-var Grid = FC.Grid = Class.extend(ListenerMixin, {
+var Grid = FC.Grid = Class.extend(ListenerMixin, MouseIgnorerMixin, {
 
 	view: null, // a View object
 	isRTL: null, // shortcut to the view's isRTL option
@@ -35,6 +35,9 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, {
 		this.view = view;
 		this.isRTL = view.opt('isRTL');
 		this.elsByFill = {};
+
+		this.dayDragListener = this.buildDayDragListener();
+		this.initMouseIgnoring();
 	},
 
 
@@ -170,12 +173,8 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, {
 		this.el = el;
 		preventSelection(el);
 
-		if (this.view.calendar.isTouch) {
-			this.bindDayHandler('touchstart', this.dayTouchStart);
-		}
-		else {
-			this.bindDayHandler('mousedown', this.dayMousedown);
-		}
+		this.bindDayHandler('touchstart', this.dayTouchStart);
+		this.bindDayHandler('mousedown', this.dayMousedown);
 
 		// attach event-element-related handlers. in Grid.events
 		// same garbage collection note as above.
@@ -253,16 +252,24 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, {
 
 	// Process a mousedown on an element that represents a day. For day clicking and selecting.
 	dayMousedown: function(ev) {
-		this.clearDragListeners();
-		this.buildDayDragListener().startInteraction(ev, {
-			//distance: 5, // needs more work if we want dayClick to fire correctly
-		});
+		if (!this.isIgnoringMouse) {
+			this.dayDragListener.startInteraction(ev, {
+				//distance: 5, // needs more work if we want dayClick to fire correctly
+			});
+		}
 	},
 
 
 	dayTouchStart: function(ev) {
-		this.clearDragListeners();
-		this.buildDayDragListener().startInteraction(ev, {
+		var view = this.view;
+
+		// HACK to prevent a user's clickaway for unselecting a range or an event
+		// from causing a dayClick.
+		if (view.isSelected || view.selectedEvent) {
+			this.tempIgnoreMouse();
+		}
+
+		this.dayDragListener.startInteraction(ev, {
 			delay: this.view.opt('longPressDelay')
 		});
 	},
@@ -280,14 +287,22 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, {
 		// this listener tracks a mousedown on a day element, and a subsequent drag.
 		// if the drag ends on the same day, it is a 'dayClick'.
 		// if 'selectable' is enabled, this listener also detects selections.
-		var dragListener = this.dayDragListener = new HitDragListener(this, {
+		var dragListener = new HitDragListener(this, {
 			scroll: view.opt('dragScroll'),
+			interactionStart: function() {
+				dayClickHit = dragListener.origHit; // for dayClick, where no dragging happens
+			},
 			dragStart: function() {
 				view.unselect(); // since we could be rendering a new selection, we want to clear any old one
 			},
 			hitOver: function(hit, isOrig, origHit) {
 				if (origHit) { // click needs to have started on a hit
-					dayClickHit = isOrig ? hit : null; // single-hit selection is a day click
+
+					// if user dragged to another cell at any point, it can no longer be a dayClick
+					if (!isOrig) {
+						dayClickHit = null;
+					}
+
 					if (isSelectable) {
 						selectionSpan = _this.computeSelection(
 							_this.getHitSpan(origHit),
@@ -308,20 +323,24 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, {
 				_this.unrenderSelection();
 				enableCursor();
 			},
-			interactionEnd: function(ev) {
-				if (dayClickHit) {
-					view.triggerDayClick(
-						_this.getHitSpan(dayClickHit),
-						_this.getHitEl(dayClickHit),
-						ev
-					);
-				}
-				if (selectionSpan) {
-					// the selection will already have been rendered. just report it
-					view.reportSelection(selectionSpan, ev);
+			interactionEnd: function(ev, isCancelled) {
+				if (!isCancelled) {
+					if (
+						dayClickHit &&
+						!_this.isIgnoringMouse // see hack in dayTouchStart
+					) {
+						view.triggerDayClick(
+							_this.getHitSpan(dayClickHit),
+							_this.getHitEl(dayClickHit),
+							ev
+						);
+					}
+					if (selectionSpan) {
+						// the selection will already have been rendered. just report it
+						view.reportSelection(selectionSpan, ev);
+					}
+					enableCursor();
 				}
-				enableCursor();
-				_this.dayDragListener = null;
 			}
 		});
 
@@ -333,9 +352,8 @@ var Grid = FC.Grid = Class.extend(ListenerMixin, {
 	// Useful for when public API methods that result in re-rendering are invoked during a drag.
 	// Also useful for when touch devices misbehave and don't fire their touchend.
 	clearDragListeners: function() {
-		if (this.dayDragListener) {
-			this.dayDragListener.endInteraction(); // will clear this.dayDragListener
-		}
+		this.dayDragListener.endInteraction();
+
 		if (this.segDragListener) {
 			this.segDragListener.endInteraction(); // will clear this.segDragListener
 		}

+ 3 - 2
src/common/HitDragListener.js

@@ -31,8 +31,6 @@ var HitDragListener = DragListener.extend({
 		var origPoint;
 		var point;
 
-		DragListener.prototype.handleInteractionStart.apply(this, arguments); // call the super-method
-
 		this.computeCoords();
 
 		if (ev) {
@@ -66,6 +64,9 @@ var HitDragListener = DragListener.extend({
 			this.origHit = null;
 			this.coordAdjust = null;
 		}
+
+		// call the super-method. do it after origHit has been computed
+		DragListener.prototype.handleInteractionStart.apply(this, arguments);
 	},
 
 

+ 27 - 0
src/common/MouseIgnorerMixin.js

@@ -0,0 +1,27 @@
+
+// simple class for toggle a `isIgnoringMouse` flag on delay
+// initMouseIgnoring must first be called, with a millisecond delay setting.
+var MouseIgnorerMixin = {
+
+	isIgnoringMouse: false, // bool
+	delayUnignoreMouse: null, // method
+
+
+	initMouseIgnoring: function(delay) {
+		this.delayUnignoreMouse = debounce(proxy(this, 'unignoreMouse'), delay || 1000);
+	},
+
+
+	// temporarily ignore mouse actions on segments
+	tempIgnoreMouse: function() {
+		this.isIgnoringMouse = true;
+		this.delayUnignoreMouse();
+	},
+
+
+	// delayUnignoreMouse eventually calls this
+	unignoreMouse: function() {
+		this.isIgnoringMouse = false;
+	}
+
+};

+ 5 - 13
src/common/View.js

@@ -390,8 +390,7 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
 	// Binds DOM handlers to elements that reside outside the view container, such as the document
 	bindGlobalHandlers: function() {
 		this.listenTo($(document), 'mousedown', this.handleDocumentMousedown);
-		this.listenTo($(document), 'touchstart', this.handleDocumentTouchStart);
-		this.listenTo($(document), 'touchend', this.handleDocumentTouchEnd);
+		this.listenTo($(document), 'touchstart', this.processUnselect);
 	},
 
 
@@ -952,25 +951,18 @@ var View = FC.View = Class.extend(EmitterMixin, ListenerMixin, {
 	/* Mouse / Touch Unselecting (time range & event unselection)
 	------------------------------------------------------------------------------------------------------------------*/
 	// TODO: move consistently to down/start or up/end?
+	// TODO: don't kill previous selection if touch scrolling
 
 
 	handleDocumentMousedown: function(ev) {
-		// touch devices fire simulated mouse events on a "click".
-		// only process mousedown if we know this isn't a touch device.
-		if (!this.calendar.isTouch && isPrimaryMouseButton(ev)) {
-			this.processRangeUnselect(ev);
-			this.processEventUnselect(ev);
+		if (isPrimaryMouseButton(ev)) {
+			this.processUnselect(ev);
 		}
 	},
 
 
-	handleDocumentTouchStart: function(ev) {
+	processUnselect: function(ev) {
 		this.processRangeUnselect(ev);
-	},
-
-
-	handleDocumentTouchEnd: function(ev) {
-		// TODO: don't do this if because of touch-scrolling
 		this.processEventUnselect(ev);
 	},
 

+ 18 - 29
src/common/common.css

@@ -73,7 +73,6 @@ body .fc { /* extra precedence to overcome jqui */
 
 .fc-icon {
 	display: inline-block;
-	width: 1em;
 	height: 1em;
 	line-height: 1em;
 	font-size: 1em;
@@ -100,7 +99,6 @@ NOTE: use percentage font sizes or else old IE chokes
 
 .fc-icon:after {
 	position: relative;
-	margin: 0 -1em; /* ensures character will be centered, regardless of width */
 }
 
 .fc-icon-left-single-arrow:after {
@@ -108,7 +106,6 @@ NOTE: use percentage font sizes or else old IE chokes
 	font-weight: bold;
 	font-size: 200%;
 	top: -7%;
-	left: 3%;
 }
 
 .fc-icon-right-single-arrow:after {
@@ -116,7 +113,6 @@ NOTE: use percentage font sizes or else old IE chokes
 	font-weight: bold;
 	font-size: 200%;
 	top: -7%;
-	left: -3%;
 }
 
 .fc-icon-left-double-arrow:after {
@@ -135,14 +131,12 @@ NOTE: use percentage font sizes or else old IE chokes
 	content: "\25C4";
 	font-size: 125%;
 	top: 3%;
-	left: -2%;
 }
 
 .fc-icon-right-triangle:after {
 	content: "\25BA";
 	font-size: 125%;
 	top: 3%;
-	left: 2%;
 }
 
 .fc-icon-down-triangle:after {
@@ -557,24 +551,19 @@ temporary rendered events).
 
 /* resizer (touch devices) */
 
-.fc-touch .fc-event .fc-resizer {
-	display: none; /* only show when selected */
+.fc-event .fc-resizer {
+	display: none;
 }
 
-.fc-touch .fc-event.fc-selected .fc-resizer {
+.fc-event.fc-allow-mouse-resize .fc-resizer,
+.fc-event.fc-selected .fc-resizer {
+	/* only show when hovering or selected (with touch) */
 	display: block;
 }
 
+/* hit area */
 
-/* Hit Area (for events and expander)
---------------------------------------------------------------------------------------------------*/
-
-.fc-expander { /* fc-event is already position:relative */
-	position: relative;
-}
-
-.fc-touch .fc-expander:before,
-.fc-touch .fc-event .fc-resizer:before {
+.fc-event.fc-selected .fc-resizer:before {
 	/* 40x40 touch area */
 	content: "";
 	position: absolute;
@@ -651,9 +640,9 @@ temporary rendered events).
 	right: -1px; /* overcome border */
 }
 
-/* resizer (cursor devices) */
+/* resizer (mouse devices) */
 
-.fc-cursor .fc-h-event .fc-resizer {
+.fc-h-event.fc-allow-mouse-resize .fc-resizer {
 	width: 7px;
 	top: -1px; /* overcome top border */
 	bottom: -1px; /* overcome bottom border */
@@ -661,7 +650,7 @@ temporary rendered events).
 
 /* resizer (touch devices) */
 
-.fc-touch .fc-h-event .fc-resizer {
+.fc-h-event.fc-selected .fc-resizer {
 	/* 8x8 little dot */
 	border-radius: 4px;
 	border-width: 1px;
@@ -676,14 +665,14 @@ temporary rendered events).
 }
 
 /* left resizer  */
-.fc-touch.fc-ltr .fc-h-event .fc-start-resizer,
-.fc-touch.fc-rtl .fc-h-event .fc-end-resizer {
+.fc-ltr .fc-h-event.fc-selected .fc-start-resizer,
+.fc-rtl .fc-h-event.fc-selected .fc-end-resizer {
 	margin-left: -4px; /* centers the 8x8 dot on the left edge */
 }
 
 /* right resizer */
-.fc-touch.fc-ltr .fc-h-event .fc-end-resizer,
-.fc-touch.fc-rtl .fc-h-event .fc-start-resizer {
+.fc-ltr .fc-h-event.fc-selected .fc-end-resizer,
+.fc-rtl .fc-h-event.fc-selected .fc-start-resizer {
 	margin-right: -4px; /* centers the 8x8 dot on the right edge */
 }
 
@@ -730,14 +719,14 @@ tr:first-child > td > .fc-day-grid-event {
 /* resizer (cursor devices) */
 
 /* left resizer  */
-.fc-cursor.fc-ltr .fc-day-grid-event .fc-start-resizer,
-.fc-cursor.fc-rtl .fc-day-grid-event .fc-end-resizer {
+.fc-ltr .fc-day-grid-event.fc-allow-mouse-resize .fc-start-resizer,
+.fc-rtl .fc-day-grid-event.fc-allow-mouse-resize .fc-end-resizer {
 	margin-left: -2px; /* to the day cell's edge */
 }
 
 /* right resizer */
-.fc-cursor.fc-ltr .fc-day-grid-event .fc-end-resizer,
-.fc-cursor.fc-rtl .fc-day-grid-event .fc-start-resizer {
+.fc-ltr .fc-day-grid-event.fc-allow-mouse-resize .fc-end-resizer,
+.fc-rtl .fc-day-grid-event.fc-allow-mouse-resize .fc-start-resizer {
 	margin-right: -2px; /* to the day cell's edge */
 }
 

+ 2 - 2
src/gcal/gcal.js

@@ -136,10 +136,10 @@ function transformOptions(sourceOptions, start, end, timezone, calendar) {
 			}
 			else if (data.items) {
 				$.each(data.items, function(i, entry) {
-					var url = entry.htmlLink;
+					var url = entry.htmlLink || null;
 
 					// make the URLs for each event show times in the correct timezone
-					if (timezoneArg) {
+					if (timezoneArg && url !== null) {
 						url = injectQsComponent(url, 'ctz=' + timezoneArg);
 					}
 

+ 1 - 4
src/main.js

@@ -1,14 +1,11 @@
 
 var FC = $.fullCalendar = {
 	version: "<%= meta.version %>",
-	internalApiVersion: 3
+	internalApiVersion: 4
 };
 var fcViews = FC.views = {};
 
 
-FC.isTouch = 'ontouchstart' in document;
-
-
 $.fn.fullCalendar = function(options) {
 	var args = Array.prototype.slice.call(arguments, 1); // for a possible method call
 	var res = this; // what this function will return (this jQuery object by default)

+ 25 - 1
src/util.js

@@ -144,7 +144,7 @@ function subtractInnerElHeight(outerEl, innerEl) {
 	var both = outerEl.add(innerEl);
 	var diff;
 
-	// fuckin IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
+	// effin' IE8/9/10/11 sometimes returns 0 for dimensions. this weird hack was the only thing that worked
 	both.css({
 		position: 'relative', // cause a reflow, which will force fresh dimension recalculation
 		left: -1 // ensure reflow in case the el was already relative. negative is less likely to cause new scroll
@@ -344,6 +344,30 @@ function preventDefault(ev) {
 }
 
 
+// attach a handler to get called when ANY scroll action happens on the page.
+// this was impossible to do with normal on/off because 'scroll' doesn't bubble.
+// http://stackoverflow.com/a/32954565/96342
+// returns `true` on success.
+function bindAnyScroll(handler) {
+	if (window.addEventListener) {
+		window.addEventListener('scroll', handler, true); // useCapture=true
+		return true;
+	}
+	return false;
+}
+
+
+// undoes bindAnyScroll. must pass in the original function.
+// returns `true` on success.
+function unbindAnyScroll(handler) {
+	if (window.removeEventListener) {
+		window.removeEventListener('scroll', handler, true); // useCapture=true
+		return true;
+	}
+	return false;
+}
+
+
 /* General Geometry Utils
 ----------------------------------------------------------------------------------------------------------------------*/
 

+ 46 - 0
tests/automated/dayClick.js

@@ -139,4 +139,50 @@ describe('dayClick', function() {
 			});
 		});
 	});
+
+	describe('when touch', function() {
+
+		it('fires correctly when simulated short drag on a cell', function(done) {
+			options.dayClick = function(date, jsEvent, view) {
+				expect(moment.isMoment(date)).toEqual(true);
+				expect(typeof jsEvent).toEqual('object'); // TODO: more descrimination
+				expect(typeof view).toEqual('object'); // "
+				expect(date.hasTime()).toEqual(false);
+				expect(date).toEqualMoment('2014-05-07');
+			};
+			spyOn(options, 'dayClick').and.callThrough();
+			$('#cal').fullCalendar(options);
+
+			var dayCell = $('.fc-day:eq(10)'); // 2014-05-07 (regardless of isRTL)
+
+			// for simulating the mousedown/mouseup/click (relevant for selectable)
+			dayCell.simulate('drag', {
+				isTouch: true,
+				callback: function() {
+					expect(options.dayClick).toHaveBeenCalled();
+					done();
+				}
+			});
+		});
+
+		it('fires correctly when simulated click on a cell', function(done) {
+			options.dayClick = function(date, jsEvent, view) {
+				expect(moment.isMoment(date)).toEqual(true);
+				expect(typeof jsEvent).toEqual('object'); // TODO: more descrimination
+				expect(typeof view).toEqual('object'); // "
+				expect(date.hasTime()).toEqual(false);
+				expect(date).toEqualMoment('2014-05-07');
+			};
+			spyOn(options, 'dayClick').and.callThrough();
+			$('#cal').fullCalendar(options);
+
+			var dayCell = $('.fc-day:eq(10)'); // 2014-05-07 (regardless of isRTL)
+
+			$.simulateTouchClick(dayCell);
+
+			expect(options.dayClick).toHaveBeenCalled();
+			done();
+		});
+	});
+
 });

+ 1 - 0
tests/automated/destroy.js

@@ -34,6 +34,7 @@ describe('destroy', function() {
 					defaultView: viewName,
 					defaultDate: '2014-12-01',
 					droppable: true, // likely to attach document handler
+					editable: true, // same
 					events: [
 						{ title: 'event1', start: '2014-12-01' }
 					]

+ 124 - 0
tests/automated/emitter.js

@@ -0,0 +1,124 @@
+
+describe('emitter', function() {
+	var EmitterMixin = $.fullCalendar.EmitterMixin;
+
+	it('calls a handler', function() {
+		var o = $.extend({}, EmitterMixin);
+		var handlers = {
+			something: function(arg1, arg2) {
+				expect(arg1).toBe(7);
+				expect(arg2).toBe(8);
+			}
+		};
+		spyOn(handlers, 'something').and.callThrough();
+
+		o.on('something', handlers.something);
+		o.trigger('something', 7, 8);
+		expect(handlers.something).toHaveBeenCalled();
+	});
+
+	it('calls a handler with context and args', function() {
+		var customContext = {};
+		var o = $.extend({}, EmitterMixin);
+		var handlers = {
+			something: function(arg1, arg2) {
+				expect(this).toBe(customContext);
+				expect(arg1).toBe(2);
+				expect(arg2).toBe(3);
+			}
+		};
+		spyOn(handlers, 'something').and.callThrough();
+
+		o.on('something', handlers.something);
+		o.triggerWith('something', customContext, [ 2, 3 ]);
+		expect(handlers.something).toHaveBeenCalled();
+	});
+
+	it('unbinds with an exact reference', function() {
+		var o = $.extend({}, EmitterMixin);
+		var handlers = {
+			something: function() {}
+		};
+		spyOn(handlers, 'something');
+
+		o.on('something', handlers.something);
+		o.trigger('something');
+		expect(handlers.something).toHaveBeenCalled();
+
+		o.off('something', handlers.something);
+		o.trigger('something');
+		expect(handlers.something.calls.count()).toBe(1);
+	});
+
+	it('unbinds all when no reference', function() {
+		var o = $.extend({}, EmitterMixin);
+		var handlers = {
+			something1: function() {},
+			something2: function() {}
+		};
+		spyOn(handlers, 'something1');
+		spyOn(handlers, 'something2');
+
+		o.on('something', handlers.something1);
+		o.on('something', handlers.something2);
+
+		o.trigger('something');
+		expect(handlers.something1).toHaveBeenCalled();
+		expect(handlers.something2).toHaveBeenCalled();
+
+		o.off('something');
+		o.trigger('something');
+		expect(handlers.something1.calls.count()).toBe(1);
+		expect(handlers.something2.calls.count()).toBe(1);
+	});
+
+	it('unbinds all', function() {
+		var o = $.extend({}, EmitterMixin);
+		var handlers = {
+			something: function() {},
+			another: function() {}
+		};
+
+		spyOn(handlers, 'something');
+		spyOn(handlers, 'another');
+
+		o.on('something', handlers.something);
+		o.on('another', handlers.another);
+
+		o.trigger('something');
+		o.trigger('another');
+		expect(handlers.something).toHaveBeenCalled();
+		expect(handlers.another).toHaveBeenCalled();
+
+		o.off();
+		o.trigger('something');
+		o.trigger('another');
+		expect(handlers.something.calls.count()).toBe(1);
+		expect(handlers.another.calls.count()).toBe(1);
+	});
+
+	it('unbinds with a namespace', function() {
+		var o = $.extend({}, EmitterMixin);
+		var handlers = {
+			something: function() {},
+			another: function() {}
+		};
+
+		spyOn(handlers, 'something');
+		spyOn(handlers, 'another');
+
+		o.on('something', handlers.something);
+		o.on('another.ns1', handlers.another);
+
+		o.trigger('something');
+		o.trigger('another');
+		expect(handlers.something).toHaveBeenCalled();
+		expect(handlers.another).toHaveBeenCalled();
+
+		o.off('.ns1');
+		o.trigger('something');
+		o.trigger('another');
+		expect(handlers.something.calls.count()).toBe(2);
+		expect(handlers.another.calls.count()).toBe(1);
+	});
+});

+ 5 - 11
tests/automated/event-dnd.js

@@ -5,7 +5,8 @@ describe('eventDrop', function() {
 		options = {
 			defaultDate: '2014-06-11',
 			editable: true,
-			dragScroll: false
+			dragScroll: false,
+			longPressDelay: 100
 		};
 		affix('#cal');
 	});
@@ -19,13 +20,11 @@ describe('eventDrop', function() {
 			options.defaultView = 'month';
 		});
 
+		// TODO: test that event's dragged via touch that don't wait long enough for longPressDelay
+		// SHOULD NOT drag
+
 		[ false, true ].forEach(function(isTouch) {
 			describe('with ' + (isTouch ? 'touch' : 'mouse'), function() {
-				beforeEach(function() {
-					options.isTouch = isTouch;
-					options.longPressDelay = isTouch ? 100 : 0;
-				});
-
 				describe('when dragging an all-day event to another day', function() {
 					it('should be given correct arguments, with whole-day delta', function(done) {
 
@@ -152,11 +151,6 @@ describe('eventDrop', function() {
 
 		[ false, true ].forEach(function(isTouch) {
 			describe('with ' + (isTouch ? 'touch' : 'mouse'), function() {
-				beforeEach(function() {
-					options.isTouch = isTouch;
-					options.longPressDelay = isTouch ? 100 : 0;
-				});
-
 				describe('when dragging a timed event to another time on a different day', function() {
 					it('should be given correct arguments and delta with days/time', function(done) {
 						options.events = [ {

+ 1 - 1
tests/automated/event-feed-param.js

@@ -24,7 +24,7 @@ describe('event feed params', function() {
 	});
 
 	afterEach(function() {
-		$.mockjaxClear();
+		$.mockjax.clear();
 	});
 
 	it('utilizes custom startParam, endParam, and timezoneParam names', function() {

+ 12 - 6
tests/automated/event-resize.js

@@ -4,7 +4,8 @@ describe('eventResize', function() {
 	beforeEach(function() {
 		options = {
 			defaultDate: '2014-06-11',
-			editable: true
+			editable: true,
+			longPressDelay: 100
 		};
 		affix('#cal');
 	});
@@ -28,6 +29,7 @@ describe('eventResize', function() {
 
 				init(
 					function() {
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dx: $('.fc-day').width() * -2.5, // guarantee 2 days to left
 							dy: $('.fc-day').height()
@@ -62,8 +64,6 @@ describe('eventResize', function() {
 					});
 
 					it('should have correct arguments with a whole-day delta', function(done) {
-						options.isTouch = true;
-						options.longPressDelay = 100;
 						options.dragRevertDuration = 0; // so that eventDragStop happens immediately after touchend
 						options.events = [ {
 							title: 'all-day event',
@@ -137,6 +137,7 @@ describe('eventResize', function() {
 
 				init(
 					function() {
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dx: $('th.fc-wed').width() * 1.5 // two days
 						});
@@ -173,6 +174,7 @@ describe('eventResize', function() {
 			it('should have correct arguments with a timed delta', function(done) {
 				init(
 					function() {
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: $('.fc-slats tr:eq(1)').height() * 4.5 // 5 slots, so 2.5 hours
 						});
@@ -196,10 +198,7 @@ describe('eventResize', function() {
 			});
 
 			it('should have correct arguments with a timed delta via touch', function(done) {
-				options.isTouch = true;
-				options.longPressDelay = 100;
 				options.dragRevertDuration = 0; // so that eventDragStop happens immediately after touchend
-
 				init(
 					function() {
 						setTimeout(function() { // wait for scroll to init, so don't do a rescroll which kills drag
@@ -237,6 +236,7 @@ describe('eventResize', function() {
 			it('should have correct arguments with a timed delta when resized to a different day', function(done) {
 				init(
 					function() {
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dx: $('.fc-day-header:first').width() * .9, // one day
 							dy: $('.fc-slats tr:eq(1)').height() * 4.5 // 5 slots, so 2.5 hours
@@ -264,6 +264,7 @@ describe('eventResize', function() {
 				options.timezone = 'local';
 				init(
 					function() {
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: $('.fc-slats tr:eq(1)').height() * 4.5 // 5 slots, so 2.5 hours
 						});
@@ -290,6 +291,7 @@ describe('eventResize', function() {
 				options.timezone = 'UTC';
 				init(
 					function() {
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: $('.fc-slats tr:eq(1)').height() * 4.5 // 5 slots, so 2.5 hours
 						});
@@ -317,6 +319,7 @@ describe('eventResize', function() {
 				options.eventAfterAllRender = function() {
 					setTimeout(function() {
 						var dy = $('.fc-slats tr:eq(1)').height() * 5; // 5 slots, so 2.5 hours
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: dy,
 							onBeforeRelease: function() {
@@ -349,6 +352,7 @@ describe('eventResize', function() {
 				options.eventAfterAllRender = function() {
 					setTimeout(function() {
 						var dy = $('.fc-slats tr:eq(1)').height() * 5; // 5 slots, so 2.5 hours
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: dy,
 							onBeforeRelease: function() {
@@ -391,6 +395,7 @@ describe('eventResize', function() {
 					alreadyRendered = true;
 					setTimeout(function() {
 						isDragging = true;
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: 100,
 							onBeforeRelease: function() {
@@ -422,6 +427,7 @@ describe('eventResize', function() {
 				options.eventAfterAllRender = function() {
 					setTimeout(function() {
 						var dy = $('.fc-slats tr:eq(1)').height() * 5; // 5 slots, so 2.5 hours
+						$('.fc-event').simulate('mouseover'); // for revealing resizer
 						$('.fc-event .fc-resizer').simulate('drag', {
 							dy: dy,
 							onBeforeRelease: function() {

+ 0 - 1
tests/automated/eventClick.js

@@ -23,7 +23,6 @@ describe('eventClick', function() {
 	});
 
 	it('works in month view via touch', function(done) {
-		options.isTouch = true;
 		options.events = [
 			{ start: '2014-08-01', title: 'event1', className: 'event1' }
 		];

+ 1 - 1
tests/automated/eventLimit-popover.js

@@ -289,7 +289,7 @@ describe('eventLimit popover', function() {
 					setTimeout(function() { // try to wait until drag is over. eventDrop won't fire BTW
 						expect($('.fc-more-popover')).toBeInDOM();
 						done();
-					},0);
+					}, 0);
 				};
 				init();
 

+ 47 - 57
tests/automated/events-gcal.js

@@ -1,11 +1,9 @@
 
-// TODO: revive
-// Google removes holidays that are old, and returns no results, breaking these tests
-xdescribe('Google Calendar plugin', function() {
+describe('Google Calendar plugin', function() {
 
 	var API_KEY = 'AIzaSyDcnW6WejpTOCffshGDDb4neIrXVUA1EAE';
+	var HOLIDAY_CALENDAR_ID = 'en.usa#[email protected]';
 	var options;
-	var currentRequest;
 	var currentWarnArgs;
 	var oldConsoleWarn;
 
@@ -14,24 +12,9 @@ xdescribe('Google Calendar plugin', function() {
 
 		options = {
 			defaultView: 'month',
-			defaultDate: '2014-11-01'
+			defaultDate: '2016-11-01'
 		};
 
-		// Mockjax is bad with JSONP (https://github.com/jakerella/jquery-mockjax/issues/136)
-		// Workaround. Wanted to use mockedAjaxCalls(), but JSONP requests get mangled later on.
-		currentRequest = null;
-		$.mockjaxSettings.log = function(mockHandler, request) {
-			currentRequest = currentRequest || $.extend({}, request); // copy
-		};
-
-		// Will cause all requests to go through $.mockjaxSettings.log, but will not actually handle
-		// any of the requests due to the JSONP bug mentioned above.
-		// THE REAL REQUESTS WILL GO THROUGH TO THE GOOGLE CALENDAR API!
-		$.mockjax({
-			url: '*',
-			responseText: {}
-		});
-
 		// Intercept calls to console.warn
 		currentWarnArgs = null;
 		oldConsoleWarn = console.warn;
@@ -41,24 +24,25 @@ xdescribe('Google Calendar plugin', function() {
 	});
 
 	afterEach(function() {
-		$.mockjaxClear();
+		$.mockjax.clear();
 		$.mockjaxSettings.log = function() { };
 		console.warn = oldConsoleWarn;
 	});
 
 	it('request/receives correctly when local timezone', function(done) {
 		options.googleCalendarApiKey = API_KEY;
-		options.events = { googleCalendarId: '[email protected]' };
+		options.events = { googleCalendarId: HOLIDAY_CALENDAR_ID };
 		options.timezone = 'local';
 		options.eventAfterAllRender = function() {
+			var currentRequest = $.mockjax.unmockedAjaxCalls()[0];
 			var events = $('#cal').fullCalendar('clientEvents');
 			var i;
 
-			expect(currentRequest.data.timeMin).toEqual('2014-10-25T00:00:00+00:00'); // one day before, by design
-			expect(currentRequest.data.timeMax).toEqual('2014-12-08T00:00:00+00:00'); // one day after, by design
+			expect(currentRequest.data.timeMin).toEqual('2016-10-29T00:00:00Z'); // one day before, by design
+			expect(currentRequest.data.timeMax).toEqual('2016-12-12T00:00:00Z'); // one day after, by design
 			expect(currentRequest.data.timeZone).toBeUndefined();
 
-			expect(events.length).toBe(4);
+			expect(events.length).toBe(5);
 			for (i = 0; i < events.length; i++) {
 				expect(events[i].url).not.toMatch('ctz=');
 			}
@@ -70,17 +54,18 @@ xdescribe('Google Calendar plugin', function() {
 
 	it('request/receives correctly when UTC timezone', function(done) {
 		options.googleCalendarApiKey = API_KEY;
-		options.events = { googleCalendarId: '[email protected]' };
+		options.events = { googleCalendarId: HOLIDAY_CALENDAR_ID };
 		options.timezone = 'UTC';
 		options.eventAfterAllRender = function() {
+			var currentRequest = $.mockjax.unmockedAjaxCalls()[0];
 			var events = $('#cal').fullCalendar('clientEvents');
 			var i;
 
-			expect(currentRequest.data.timeMin).toEqual('2014-10-25T00:00:00+00:00'); // one day before, by design
-			expect(currentRequest.data.timeMax).toEqual('2014-12-08T00:00:00+00:00'); // one day after, by design
+			expect(currentRequest.data.timeMin).toEqual('2016-10-29T00:00:00Z'); // one day before, by design
+			expect(currentRequest.data.timeMax).toEqual('2016-12-12T00:00:00Z'); // one day after, by design
 			expect(currentRequest.data.timeZone).toEqual('UTC');
 
-			expect(events.length).toBe(4);
+			expect(events.length).toBe(5);
 			for (i = 0; i < events.length; i++) {
 				expect(events[i].url).toMatch('ctz=UTC');
 			}
@@ -92,17 +77,18 @@ xdescribe('Google Calendar plugin', function() {
 
 	it('request/receives correctly when custom timezone', function(done) {
 		options.googleCalendarApiKey = API_KEY;
-		options.events = { googleCalendarId: '[email protected]' };
+		options.events = { googleCalendarId: HOLIDAY_CALENDAR_ID };
 		options.timezone = 'America/New York';
 		options.eventAfterAllRender = function() {
+			var currentRequest = $.mockjax.unmockedAjaxCalls()[0];
 			var events = $('#cal').fullCalendar('clientEvents');
 			var i;
 
-			expect(currentRequest.data.timeMin).toEqual('2014-10-25T00:00:00+00:00'); // one day before, by design
-			expect(currentRequest.data.timeMax).toEqual('2014-12-08T00:00:00+00:00'); // one day after, by design
+			expect(currentRequest.data.timeMin).toEqual('2016-10-29T00:00:00Z'); // one day before, by design
+			expect(currentRequest.data.timeMax).toEqual('2016-12-12T00:00:00Z'); // one day after, by design
 			expect(currentRequest.data.timeZone).toEqual('America/New_York'); // space should be escaped
 
-			expect(events.length).toBe(4);
+			expect(events.length).toBe(5);
 			for (i = 0; i < events.length; i++) {
 				expect(events[i].url).toMatch('ctz=America/New_York');
 			}
@@ -114,22 +100,23 @@ xdescribe('Google Calendar plugin', function() {
 
 	it('requests/receives correctly when no timezone, defaults to not editable', function(done) {
 		options.googleCalendarApiKey = API_KEY;
-		options.events = { googleCalendarId: '[email protected]' };
+		options.events = { googleCalendarId: HOLIDAY_CALENDAR_ID };
 		options.eventAfterAllRender = function() {
+			var currentRequest = $.mockjax.unmockedAjaxCalls()[0];
 			var events = $('#cal').fullCalendar('clientEvents');
 			var eventEls = $('.fc-event');
 			var i;
 
-			expect(currentRequest.data.timeMin).toEqual('2014-10-25T00:00:00+00:00'); // one day before, by design
-			expect(currentRequest.data.timeMax).toEqual('2014-12-08T00:00:00+00:00'); // one day after, by design
+			expect(currentRequest.data.timeMin).toEqual('2016-10-29T00:00:00Z'); // one day before, by design
+			expect(currentRequest.data.timeMax).toEqual('2016-12-12T00:00:00Z'); // one day after, by design
 			expect(currentRequest.data.timeZone).toBeUndefined();
 
-			expect(events.length).toBe(4); // 4 holidays in November 2014
+			expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 			for (i = 0; i < events.length; i++) {
 				expect(events[i].url).not.toMatch('ctz=');
 			}
 
-			expect(eventEls.length).toBe(4);
+			expect(eventEls.length).toBe(5);
 			expect(eventEls.find('.fc-resizer').length).toBe(0); // not editable
 
 			done();
@@ -140,12 +127,12 @@ xdescribe('Google Calendar plugin', function() {
 	it('allows editable to explicitly be set to true', function(done) {
 		options.googleCalendarApiKey = API_KEY;
 		options.events = {
-			googleCalendarId: '[email protected]',
+			googleCalendarId: HOLIDAY_CALENDAR_ID,
 			editable: true
 		};
 		options.eventAfterAllRender = function() {
 			var eventEls = $('.fc-event');
-			expect(eventEls.length).toBe(4);
+			expect(eventEls.length).toBe(5);
 			expect(eventEls.find('.fc-resizer').length).toBeGreaterThan(0); // editable!
 			done();
 		};
@@ -154,12 +141,12 @@ xdescribe('Google Calendar plugin', function() {
 
 	it('fetches events correctly when API key is in the event source', function(done) {
 		options.events = {
-			googleCalendarId: '[email protected]',
+			googleCalendarId: HOLIDAY_CALENDAR_ID,
 			googleCalendarApiKey: API_KEY
 		};
 		options.eventAfterAllRender = function() {
 			var events = $('#cal').fullCalendar('clientEvents');
-			expect(events.length).toBe(4); // 4 holidays in November 2014
+			expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 			done();
 		};
 		$('#cal').fullCalendar(options);
@@ -174,15 +161,17 @@ xdescribe('Google Calendar plugin', function() {
 				googleCalendarError: function(err) {
 					expect(typeof err).toBe('object');
 				},
-				googleCalendarId: '[email protected]'
+				googleCalendarId: HOLIDAY_CALENDAR_ID
 			};
 			options.eventAfterAllRender = function() {
+				var currentRequest = $.mockjax.unmockedAjaxCalls()[0];
 				var events = $('#cal').fullCalendar('clientEvents');
+
 				expect(events.length).toBe(0);
 				expect(currentWarnArgs.length).toBeGreaterThan(0);
 				expect(options.googleCalendarError).toHaveBeenCalled();
 				expect(options.events.googleCalendarError).toHaveBeenCalled();
-				expect(currentRequest).toBeNull(); // AJAX request should have never been made!
+				expect(currentRequest).toBeUndefined(); // AJAX request should have never been made!
 				done();
 			};
 			spyOn(options, 'googleCalendarError').and.callThrough();
@@ -201,10 +190,12 @@ xdescribe('Google Calendar plugin', function() {
 				googleCalendarError: function(err) {
 					expect(typeof err).toBe('object');
 				},
-				googleCalendarId: '[email protected]'
+				googleCalendarId: HOLIDAY_CALENDAR_ID
 			};
 			options.eventAfterAllRender = function() {
+				var currentRequest = $.mockjax.unmockedAjaxCalls()[0];
 				var events = $('#cal').fullCalendar('clientEvents');
+
 				expect(events.length).toBe(0);
 				expect(currentWarnArgs.length).toBeGreaterThan(0);
 				expect(options.googleCalendarError).toHaveBeenCalled();
@@ -220,10 +211,10 @@ xdescribe('Google Calendar plugin', function() {
 
 	it('works when `events` is the actual calendar ID', function(done) {
 		options.googleCalendarApiKey = API_KEY;
-		options.events = '[email protected]';
+		options.events = HOLIDAY_CALENDAR_ID;
 		options.eventAfterAllRender = function() {
 			var events = $('#cal').fullCalendar('clientEvents');
-			expect(events.length).toBe(4); // 4 holidays in November 2014
+			expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 			done();
 		};
 		$('#cal').fullCalendar(options);
@@ -278,7 +269,7 @@ xdescribe('Google Calendar plugin', function() {
 		options.events = 'http://www.google.com/calendar/feeds/usa__en%40holiday.calendar.google.com/public/basic';
 		options.eventAfterAllRender = function() {
 			var events = $('#cal').fullCalendar('clientEvents');
-			expect(events.length).toBe(4); // 4 holidays in November 2014
+			expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 			done();
 		};
 		$('#cal').fullCalendar(options);
@@ -289,7 +280,7 @@ xdescribe('Google Calendar plugin', function() {
 		options.events = 'https://www.google.com/calendar/feeds/usa__en%40holiday.calendar.google.com/public/basic';
 		options.eventAfterAllRender = function() {
 			var events = $('#cal').fullCalendar('clientEvents');
-			expect(events.length).toBe(4); // 4 holidays in November 2014
+			expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 			done();
 		};
 		$('#cal').fullCalendar(options);
@@ -301,7 +292,7 @@ xdescribe('Google Calendar plugin', function() {
 			'https://www.googleapis.com/calendar/v3/calendars/usa__en%40holiday.calendar.google.com/events';
 		options.eventAfterAllRender = function() {
 			var events = $('#cal').fullCalendar('clientEvents');
-			expect(events.length).toBe(4); // 4 holidays in November 2014
+			expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 			done();
 		};
 		$('#cal').fullCalendar(options);
@@ -310,11 +301,11 @@ xdescribe('Google Calendar plugin', function() {
 	describe('removeEventSource', function() {
 
 		it('works when specifying only the Google Calendar ID', function(done) {
-			var CALENDAR_ID = '[email protected]';
+			var CALENDAR_ID = HOLIDAY_CALENDAR_ID;
 			var called = false;
 
 			options.googleCalendarApiKey = API_KEY;
-			options.eventSources = [ { googleCalendarId: CALENDAR_ID } ];
+			options.eventSources = [ { googleCalendarId: HOLIDAY_CALENDAR_ID } ];
 			options.eventAfterAllRender = function() {
 				var events;
 
@@ -322,10 +313,10 @@ xdescribe('Google Calendar plugin', function() {
 				called = true;
 
 				events = $('#cal').fullCalendar('clientEvents');
-				expect(events.length).toBe(4); // 4 holidays in November 2014
+				expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 
 				setTimeout(function() {
-					$('#cal').fullCalendar('removeEventSource', CALENDAR_ID);
+					$('#cal').fullCalendar('removeEventSource', HOLIDAY_CALENDAR_ID);
 					events = $('#cal').fullCalendar('clientEvents');
 					expect(events.length).toBe(0);
 					done();
@@ -336,8 +327,7 @@ xdescribe('Google Calendar plugin', function() {
 		});
 
 		it('works when specifying a raw Google Calendar source object', function(done) {
-			var CALENDAR_ID = '[email protected]';
-			var googleSource = { googleCalendarId: CALENDAR_ID };
+			var googleSource = { googleCalendarId: HOLIDAY_CALENDAR_ID };
 			var called = false;
 
 			options.googleCalendarApiKey = API_KEY;
@@ -349,7 +339,7 @@ xdescribe('Google Calendar plugin', function() {
 				called = true;
 
 				events = $('#cal').fullCalendar('clientEvents');
-				expect(events.length).toBe(4); // 4 holidays in November 2014
+				expect(events.length).toBe(5); // 5 holidays in November 2016 (and end of Oct)
 
 				setTimeout(function() {
 					$('#cal').fullCalendar('removeEventSource', googleSource);

+ 1 - 1
tests/automated/events-json-feed.js

@@ -25,7 +25,7 @@ describe('events as a json feed', function() {
 	});
 
 	afterEach(function() {
-		$.mockjaxClear();
+		$.mockjax.clear();
 	});
 
 	it('requests correctly when no timezone', function() {

+ 2 - 0
tests/automated/lang.js

@@ -42,6 +42,7 @@ describe('lang', function() {
 	// the most recent version of moment will actually throw a cryptic exception,
 	// and instead of papering over this, just let it be thrown. will indicate that something
 	// needs to be fixed to the developer.
+	/*
 	xit('defaults to English when configured to language that isn\'t loaded', function() {
 		affix('#cal');
 		$('#cal').fullCalendar({
@@ -52,6 +53,7 @@ describe('lang', function() {
 		var s = mom.format('dddd MMMM Do YYYY');
 		expect(s).toEqual('Thursday May 1st 2014');
 	});
+	*/
 
 	it('works when certain language has no FC settings defined', function() {
 		affix('#cal');

+ 1 - 1
tests/automated/removeEventSource.js

@@ -15,7 +15,7 @@ describe('removeEventSource', function() {
 	});
 
 	afterEach(function() {
-		$.mockjaxClear();
+		$.mockjax.clear();
 	});
 
 	describe('with a URL', function() {

+ 3 - 6
tests/automated/select-callback.js

@@ -6,7 +6,8 @@ describe('select callback', function() {
 		affix('#cal');
 		options = {
 			defaultDate: '2014-05-25',
-			selectable: true
+			selectable: true,
+			longPressDelay: 100
 		};
 	});
 
@@ -45,8 +46,6 @@ describe('select callback', function() {
 					});
 				});
 				it('gets fired correctly when the user selects cells via touch', function(done) {
-					options.isTouch = true;
-					options.longPressDelay = 100;
 					options.select = function(start, end, jsEvent, view) {
 						expect(moment.isMoment(start)).toEqual(true);
 						expect(moment.isMoment(end)).toEqual(true);
@@ -162,8 +161,6 @@ describe('select callback', function() {
 						});
 					});
 					it('gets fired correctly when the user selects slots via touch', function(done) {
-						options.isTouch = true;
-						options.longPressDelay = 1000;
 						options.select = function(start, end, jsEvent, view) {
 							expect(moment.isMoment(start)).toEqual(true);
 							expect(moment.isMoment(end)).toEqual(true);
@@ -179,7 +176,7 @@ describe('select callback', function() {
 						setTimeout(function() { // prevent scroll from being triggered, killing the select interaction
 							$('.fc-slats tr:eq(18) td:not(.fc-time)').simulate('drag', { // middle will be 2014-05-28T09:00:00
 								isTouch: true,
-								delay: 1500,
+								delay: 200,
 								dy: $('.fc-slats tr:eq(18)').outerHeight() * 2, // move down two slots
 								callback: function() {
 									expect(options.select).toHaveBeenCalled();

+ 2 - 2
tests/automated/timezone.js

@@ -81,10 +81,10 @@ describe('timezone', function() {
 			expect(allDayEvent.start.format()).toEqual('2014-05-02');
 			expect(timedEvent.start.hasZone()).toEqual(true);
 			expect(timedEvent.start.hasTime()).toEqual(true);
-			expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00+00:00');
+			expect(timedEvent.start.format()).toEqual('2014-05-10T12:00:00Z');
 			expect(zonedEvent.start.hasZone()).toEqual(true);
 			expect(zonedEvent.start.hasTime()).toEqual(true);
-			expect(zonedEvent.start.format()).toEqual('2014-05-10T03:00:00+00:00');
+			expect(zonedEvent.start.format()).toEqual('2014-05-10T03:00:00Z');
 			done();
 		};
 		$('#cal').fullCalendar(options);

+ 1 - 0
tests/lib/dnd-resize-utils.js

@@ -125,6 +125,7 @@ function testEventResize(options, resizeDate, expectSuccess, callback, eventClas
 		expect(dragEl.length).toBe(1);
 		dx = lastDayEl.offset().left + lastDayEl.outerWidth() - 2 - (eventEl.offset().left + eventEl.outerWidth());
 
+		dragEl.simulate('mouseover'); // resizer only shows up on mouseover
 		dragEl.simulate('drag', {
 			dx: dx,
 			dy: dy,

+ 2 - 4
tests/lib/jasmine-ext.js

@@ -7,10 +7,8 @@ beforeEach(function() {
 	// (not the best place for this)
 	moment.suppressDeprecationWarnings = true;
 
-	// phantom JS falsely reports touch abilities, so explicitly disable.
-	// tests can override this on a per-calendar basis.
-	// (not the best place for this)
-	$.fullCalendar.isTouch = false;
+	// increase the default timeout
+	jasmine.DEFAULT_TIMEOUT_INTERVAL = 10000;
 
 
 	jasmine.addMatchers({

+ 4 - 2
tests/lib/simulate.js

@@ -53,9 +53,11 @@ $.simulate.prototype.simulateTouchEvent = function(elem, type, options) {
 $.simulateTouchClick = function(elem) {
 	var $elem = $(elem);
 	var clientCoords = {
-		clientX: $elem.offset().left,
-		clientY: $elem.offset().top
+		clientX: $elem.offset().left + $elem.outerWidth() / 2,
+		clientY: $elem.offset().top + $elem.outerHeight() / 2
 	};
+	$elem.simulate('touchstart', clientCoords);
+	$elem.simulate('touchend', clientCoords);
 	$elem.simulate('mousemove', clientCoords);
 	$elem.simulate('mousedown', clientCoords);
 	$elem.simulate('mouseup', clientCoords);

+ 57 - 0
tests/manual_gh_3152.html

@@ -0,0 +1,57 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset='utf-8' />
+<link href='../dist/fullcalendar.css' rel='stylesheet' />
+<link href='../dist/fullcalendar.print.css' rel='stylesheet' media='print' />
+<script src='../lib/moment/moment.js'></script>
+<script src='../lib/jquery/dist/jquery.js'></script>
+<script src='../dist/fullcalendar.js'></script>
+<script>
+
+	$(document).ready(function() {
+
+		$('#calendar').fullCalendar({
+			header: {
+				left: 'prev,next today',
+				center: 'title',
+				right: 'month,agendaWeek,agendaDay'
+			},
+			defaultDate: '2014-06-12',
+			defaultView: 'agendaWeek',
+			editable: true,
+			dayClick: function() {
+				alert('dayClick');
+			}
+		});
+
+	});
+
+</script>
+<style>
+
+	body {
+		margin: 0;
+		padding: 0;
+		font-family: "Lucida Grande",Helvetica,Arial,Verdana,sans-serif;
+		font-size: 13px;
+	}
+
+	#calendar {
+		width: 900px;
+		margin: 40px auto;
+	}
+
+</style>
+</head>
+<body>
+
+<p>
+	On a touch device, begin to scroll on the timeslots.<br />
+	If <em>dayClick</em> pops up, the test has <strong>FAILED</strong>.
+</p>
+
+<div id='calendar'></div>
+
+</body>
+</html>

+ 95 - 0
tests/manual_gh_3160.html

@@ -0,0 +1,95 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset='utf-8' />
+<link href='../dist/fullcalendar.css' rel='stylesheet' />
+<link href='../dist/fullcalendar.print.css' rel='stylesheet' media='print' />
+<script src='../lib/moment/moment.js'></script>
+<script src='../lib/jquery/dist/jquery.js'></script>
+<script src='../dist/fullcalendar.js'></script>
+<script>
+
+	$(document).ready(function() {
+
+		$('#calendar').fullCalendar({
+			header: {
+				left: 'prev,next today',
+				center: 'title',
+				right: 'month,agendaWeek,agendaDay'
+			},
+			defaultDate: '2014-06-12',
+			defaultView: 'agendaWeek',
+			editable: true,
+			height: 'auto',
+			events: [
+				{
+					title: 'All Day Event',
+					start: '2014-06-01'
+				},
+				{
+					title: 'Long Event',
+					start: '2014-06-07',
+					end: '2014-06-10'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2014-06-09T16:00:00'
+				},
+				{
+					id: 999,
+					title: 'Repeating Event',
+					start: '2014-06-16T16:00:00'
+				},
+				{
+					title: 'Meeting',
+					start: '2014-06-12T10:30:00',
+					end: '2014-06-12T12:30:00'
+				},
+				{
+					title: 'Lunch',
+					start: '2014-06-12T12:00:00'
+				},
+				{
+					title: 'Birthday Party',
+					start: '2014-06-13T07:00:00'
+				},
+				{
+					title: 'Click for Google',
+					url: 'http://google.com/',
+					start: '2014-06-28'
+				}
+			]
+		});
+
+	});
+
+</script>
+<style>
+
+	body {
+		margin: 0;
+		padding: 0;
+		font-family: "Lucida Grande",Helvetica,Arial,Verdana,sans-serif;
+		font-size: 13px;
+	}
+
+	#calendar {
+		width: 900px;
+		margin: 40px auto;
+	}
+
+</style>
+</head>
+<body>
+
+<p>
+	Make sure there is limited vertical space so the window scrolls.<br />
+	Then touch and hold on an event and begin scrolling. Hold down for 3+ seconds.<br />
+	If the event becomes selected and draggable, the test has <strong>FAILED</strong>.
+</p>
+
+<div id='calendar'></div>
+
+</body>
+</html>

+ 77 - 0
tests/touch-firing.html

@@ -0,0 +1,77 @@
+<!DOCTYPE html>
+<html>
+<head>
+<meta charset='utf-8' />
+<script src='../lib/jquery/dist/jquery.js'></script>
+<script>
+
+	$(function() {
+
+		$(document)
+			.on('mousedown', function(ev) {
+				console.log('mousedown'); //, ev.target);
+			})
+			.on('mouseup', function(ev) {
+				console.log('mouseup'); //, ev.target);
+			})
+			.on('mousemove', function(ev) {
+				console.log('mousemove'); //, ev.target);
+			})
+			.on('mouseover', function(ev) {
+				console.log('mouseover'); //, ev.target);
+			})
+			.on('mouseout', function(ev) {
+				console.log('mouseout'); //, ev.target);
+			})
+			.on('click', function(ev) {
+				console.log('click'); //, ev.target);
+			})
+			.on('touchstart', function(ev) {
+				console.log('touchstart'); //, ev.target);
+			})
+			.on('touchend', function(ev) {
+				console.log('touchend'); //, ev.target);
+			})
+			.on('touchmove', function(ev) {
+				console.log('touchmove'); //, ev.target);
+				//ev.preventDefault();
+			});
+
+		$('#scroll')
+			.on('scroll', function(ev) {
+				console.log('scroll'); //, ev.target);
+			});
+
+		/*
+
+		tap:
+		+touchstart
+		(delay)
+		+touchend
+		(delay)
+		+mousemove
+		+mousedown
+		+mouseup
+		+click
+
+		*/
+
+	});
+
+</script>
+<style>
+
+</style>
+</head>
+<body>
+
+	<div id='scroll' style='width:400px;height:400px;overflow:auto;border:1px solid #000;float:left'>
+		<div style='width:800px;height:800px'>
+			test
+		</div>
+	</div>
+
+	<a href='#' style='float:left;margin-left:1em'>this is a link</a>
+
+</body>
+</html>