From 0dd20c0a0dcac1b4cd26ed8502629043bcdbcf83 Mon Sep 17 00:00:00 2001 From: Jan Suwart Date: Sun, 3 Dec 2017 12:46:10 +0100 Subject: [PATCH] Fix parsing of uppercase PM postfix, add mocha/chai specs and test page, resolves #13 --- .editorconfig | 20 +++++ README.md | 14 ++-- css/appointment-picker.css | 2 +- css/appointment-picker.scss | 2 +- index.html | 4 + js/appointment-picker.js | 38 ++++----- js/appointment-picker.min.js | 2 +- package.json | 2 +- tests/test.js | 145 +++++++++++++++++++++++++++++++++++ tests/tests.html | 40 ++++++++++ 10 files changed, 241 insertions(+), 28 deletions(-) create mode 100644 .editorconfig create mode 100644 tests/test.js create mode 100644 tests/tests.html diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..fc61982 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,20 @@ +# http://EditorConfig.org + +root = true + +[*] +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true + +[*.{js}] +indent_style = tab +indent_size = 4 + +[*.scss] +indent_style = space +indent_size = 4 + +[*.md] +trim_trailing_whitespace = false \ No newline at end of file diff --git a/README.md b/README.md index a0e631e..8afb1dd 100644 --- a/README.md +++ b/README.md @@ -142,18 +142,22 @@ document.body.addEventListener('change.appo.picker', function(e) { var time = e. ## Styling All appointment-picker styles are namespaced with `.appo-picker`, i.e. `.appo-picker-list-item`. Depending on your project, you can either overwrite them using your own CSS or by modifying the provided CSS. +## Accessibility + +For screen reader support add both a `aria-label` and `aria-live` properties on the input field +```html + +``` + ## Best practices - appointment-picker neither installs any event listeners outside of the input nor it adds any dom elements until it is opened by the user - it can be destroyed using its the exposed destroy method that causes all event listeners and dom elements to be removed (i.e. if used in a single page application) -- for better screen reader support it is recomended to add both a `aria-label` and `aria-live` properties on the input field - ```html - - ``` +- there is automated testing ([Mocha](https://mochajs.org) and [Chai](http://chaijs.com/api/assert)) to assert that the exposed core functions and the date parser behave correctly ([see specs page](https://jannicz.github.io/appointment-picker/tests/tests.html)) ## Browser Support (tested) - Chrome - Firefox -- Safari (macOS 11 & iOS 10) +- Safari (macOS 10 & iOS 9) - Edge - IE11 / IE10 diff --git a/css/appointment-picker.css b/css/appointment-picker.css index f855c8e..5fb3260 100644 --- a/css/appointment-picker.css +++ b/css/appointment-picker.css @@ -1,5 +1,5 @@ /* - * appointment-picker.css 1.0.4 | MIT License | github.com/jannicz/appointment-picker + * appointment-picker.css 1.0.5 | MIT License | github.com/jannicz/appointment-picker */ /* Default variation */ .appo-picker { diff --git a/css/appointment-picker.scss b/css/appointment-picker.scss index 34ecee8..50f023d 100644 --- a/css/appointment-picker.scss +++ b/css/appointment-picker.scss @@ -1,5 +1,5 @@ /* - * appointment-picker.css 1.0.4 | MIT License | github.com/jannicz/appointment-picker + * appointment-picker.css 1.0.5 | MIT License | github.com/jannicz/appointment-picker */ /* Default variation */ diff --git a/index.html b/index.html index 771e90c..1355d1b 100644 --- a/index.html +++ b/index.html @@ -124,6 +124,10 @@

More Examples

  • Calling exposed functions
  • Rendering into DOM on init
  • +

    Tests

    +

    Report bugs or feature requests on Github Issues

    diff --git a/js/appointment-picker.js b/js/appointment-picker.js index 0e1a52c..7abda70 100644 --- a/js/appointment-picker.js +++ b/js/appointment-picker.js @@ -2,7 +2,7 @@ * Appointment-Picker - a lightweight, accessible and customizable timepicker * * @module Appointment-Picker - * @version 1.0.4 + * @version 1.0.5 * * @author Jan Suwart */ @@ -59,7 +59,7 @@ this.closeEventFn = this.close.bind(this); this.openEventFn = this.open.bind(this); this.keyEventFn = this.onKeyPress.bind(this); - this.tabKeyUpEventFn = this.onTabKeyUp.bind(this); + this.bodyFocusEventFn = this.onBodyFocus.bind(this); initialize(this, el, options || {}); }; @@ -67,7 +67,7 @@ /** * Initialize the picker, merge default options and check for errors * @param {Object} _this - this view reference - * @param {DOMnode} el - reference to the time input field + * @param {HTMLElement} el - reference to the time input field * @param {Object} options - user defined options */ function initialize(_this, el, options) { @@ -151,7 +151,7 @@ // Delay document click listener to prevent picker flashing setTimeout(function() { document.body.addEventListener('click', _this.closeEventFn); - document.body.addEventListener('focus', _this.tabKeyUpEventFn, true); + document.body.addEventListener('focus', _this.bodyFocusEventFn, true); }, 100); }; @@ -185,7 +185,7 @@ this.picker.removeEventListener('click', this.selectionEventFn); this.picker.removeEventListener('keyup', this.keyEventFn); document.body.removeEventListener('click', this.closeEventFn); - document.body.removeEventListener('focus', this.tabKeyUpEventFn, true); + document.body.removeEventListener('focus', this.bodyFocusEventFn, true); }; /** @@ -238,7 +238,7 @@ }; // Close the picker on document focus, usually by hitting TAB - AppointmentPicker.prototype.onTabKeyUp = function(e) { + AppointmentPicker.prototype.onBodyFocus = function(e) { if (!this.isOpen) return; this.close(e); }; @@ -333,26 +333,26 @@ }; /** - * @param {String} time - string that needs to be parsed, i.e. '11:15PM ' + * @param {String} time - string that needs to be parsed, i.e. '11:15PM ' or '10:30 am' * @returns {Object|undefined} containing {h: hour, m: minute} or undefined if unrecognized - * @see https://regexr.com/3h7bo + * @see https://regexr.com/3heaj */ function _parseTime(time) { - var match = time.match(/^([\d]{1,2}):([\d]{2})[\s]*([ap][m])?.*$/); - var hour; + var match = time.match(/^\s*([\d]{1,2}):([\d]{2})[\s]*([ap][m])?.*$/i); if (match) { - if (match[3] === 'pm' && match[1] !== '12') { - hour = Number(match[1]) + 12; - } else if (match[3] === 'am' && match[1] === '12') { + var hour = Number(match[1]); + var minute = Number(match[2]); + var postfix = match[3]; + + if (/pm/i.test(postfix) && hour !== 12) { + hour += 12; + } else if (/am/i.test(postfix) && hour === 12) { hour = 0; - } else { - hour = match[1] } - return { h: Number(hour), m: Number(match[2]) }; + return { h: hour, m: minute }; } - - return undefined; + return; }; /** @@ -385,7 +385,7 @@ var next = direction < 0 ? item.previousElementSibling : item.nextElementSibling; if (next && next.className.indexOf('disabled') < 0) { return next; - } else { // If .disabled found, try the next sibling + } else { // If disabled class found, try the next sibling return _getNextSibling(next, direction); } }; diff --git a/js/appointment-picker.min.js b/js/appointment-picker.min.js index e06eedb..f80d9b8 100644 --- a/js/appointment-picker.min.js +++ b/js/appointment-picker.min.js @@ -1 +1 @@ -!function(e,t){"object"==typeof exports?module.exports=t():"function"==typeof define&&define.amd?define("appointment-picker",[],function(){return t()}):e.AppointmentPicker=t()}(this,function(){"use strict";function e(e,t,i,s,n){var r=!1;return!(ei.maxTime||e>24)&&(!(s.indexOf(t)<0)&&(n.forEach(function(i,s){i.h===e&&i.m===t&&(r=!0)}),!r))}function t(e){var t,i=e.match(/^([\d]{1,2}):([\d]{2})[\s]*([ap][m])?.*$/);if(i)return t="pm"===i[3]&&"12"!==i[1]?Number(i[1])+12:"am"===i[3]&&"12"===i[1]?0:i[1],{h:Number(t),m:Number(i[2])}}function i(e,t,i,s){var n=e;return s&&(e>12?n=e-12:0==e&&(n=12),i=i.replace(e<12?"p":"a","")),i.replace("H",n).replace("M",function(e){return/^[0-9]{1}$/.test(e)?"0"+e:e}(t))}function s(e,t){if(!e)return null;var i=t<0?e.previousElementSibling:e.nextElementSibling;return i&&i.className.indexOf("disabled")<0?i:s(i,t)}var n=function(e,i){this.options={interval:60,minTime:0,maxTime:24,startTime:0,endTime:24,disabled:[],mode:"24h",large:!1,static:!1,title:"Timepicker"},this.template={inner:'
  • ',outer:'{{title}}',time12:"H:M apm",time24:"H:M"},this.el=e,this.picker=null,this.isOpen=!1,this.isInDom=!1,this.time={},this.intervals=[],this.disabledArr=[],this.displayTime="",this.selectionEventFn=this.select.bind(this),this.changeEventFn=this.onchange.bind(this),this.closeEventFn=this.close.bind(this),this.openEventFn=this.open.bind(this),this.keyEventFn=this.onKeyPress.bind(this),this.tabKeyUpEventFn=this.onTabKeyUp.bind(this),function(e,i,s){for(var n in s)e.options[n]=s[n];if(e.el)if(void 0===e.el.length)if(e.options.interval>60)console.warn("appointment-picker: the maximal interval is 60");else{e.options.disabled.forEach(function(i,s){e.disabledArr[s]=t(e.options.disabled[s])});for(var r=0;r<60/e.options.interval;r++)e.intervals[r]=r*e.options.interval;e.options.static?(e.picker=e.build(),e.picker.classList.add("is-position-static"),e.picker.addEventListener("click",e.selectionEventFn),e.isInDom=!0):i.addEventListener("focus",e.openEventFn),e.setTime(e.el.value),i.addEventListener("keyup",e.keyEventFn),i.addEventListener("change",e.changeEventFn)}else console.warn("appointment-picker: pass only one dom element as argument")}(this,e,i||{})};return n.prototype.render=function(){if(this.isOpen){var e=this.el.offsetTop+this.el.offsetHeight,t=this.el.offsetLeft,i=this.picker.querySelector("input.is-selected");if(this.picker.classList.add("is-open"),i&&i.classList.remove("is-selected"),this.time.hasOwnProperty("h")){var s=this.picker.querySelector('[value="'+this.displayTime+'"]');s&&s.classList.add("is-selected")}this.picker.style.top=e+"px",this.picker.style.left=t+"px"}else this.picker.classList.remove("is-open")},n.prototype.open=function(){var e=this;this.isOpen||(this.isInDom||(this.picker=this.build(),this.isInDom=!0),this.isOpen=!0,this.render(),this.picker.addEventListener("click",this.selectionEventFn),this.picker.addEventListener("keyup",this.keyEventFn),setTimeout(function(){document.body.addEventListener("click",e.closeEventFn),document.body.addEventListener("focus",e.tabKeyUpEventFn,!0)},100))},n.prototype.close=function(e){if(this.isOpen){if(Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),e){var t=e.target;if(t.isEqualNode(this.el))return;for(;t;){if(t.matches(".appo-picker"))return;t=t.parentElement}}this.isOpen=!1,this.render(),this.picker.removeEventListener("click",this.selectionEventFn),this.picker.removeEventListener("keyup",this.keyEventFn),document.body.removeEventListener("click",this.closeEventFn),document.body.removeEventListener("focus",this.tabKeyUpEventFn,!0)}},n.prototype.select=function(e){var t=this;e.target.value&&(this.setTime(e.target.value),this.el.focus(),setTimeout(function(){t.close(null)},100))},n.prototype.onchange=function(e){this.setTime(this.el.value)},n.prototype.onKeyPress=function(e){var t=this.picker.querySelector('input[type="button"]:not([disabled])'),i=this.picker.querySelector("input.is-selected"),n=null;switch(e.keyCode){case 13:case 27:this.close(null);break;case 38:n=i?s(i.parentNode,-1):t.parentNode;break;case 40:n=i?s(i.parentNode,1):t.parentNode}n&&!n.firstChild.disabled&&(n.firstChild.classList.add("is-selected"),i&&i.classList.remove("is-selected"),this.setTime(n.firstChild.value))},n.prototype.onTabKeyUp=function(e){this.isOpen&&this.close(e)},n.prototype.build=function(){var t=document.createElement("div");return t.innerHTML=function(t,s,n,r){for(var o=t.startTime,a=t.endTime,l="",p="12h"===t.mode,c=p?s.time12:s.time24,h=o;hi.maxTime||e>24)&&(!(s.indexOf(t)<0)&&(n.forEach(function(i,s){i.h===e&&i.m===t&&(r=!0)}),!r))}function t(e){var t=e.match(/^\s*([\d]{1,2}):([\d]{2})[\s]*([ap][m])?.*$/i);if(t){var i=Number(t[1]),s=Number(t[2]),n=t[3];return/pm/i.test(n)&&12!==i?i+=12:/am/i.test(n)&&12===i&&(i=0),{h:i,m:s}}}function i(e,t,i,s){var n=e;return s&&(e>12?n=e-12:0==e&&(n=12),i=i.replace(e<12?"p":"a","")),i.replace("H",n).replace("M",function(e){return/^[0-9]{1}$/.test(e)?"0"+e:e}(t))}function s(e,t){if(!e)return null;var i=t<0?e.previousElementSibling:e.nextElementSibling;return i&&i.className.indexOf("disabled")<0?i:s(i,t)}var n=function(e,i){this.options={interval:60,minTime:0,maxTime:24,startTime:0,endTime:24,disabled:[],mode:"24h",large:!1,static:!1,title:"Timepicker"},this.template={inner:'
  • ',outer:'{{title}}
      {{innerHtml}}
    ',time12:"H:M apm",time24:"H:M"},this.el=e,this.picker=null,this.isOpen=!1,this.isInDom=!1,this.time={},this.intervals=[],this.disabledArr=[],this.displayTime="",this.selectionEventFn=this.select.bind(this),this.changeEventFn=this.onchange.bind(this),this.closeEventFn=this.close.bind(this),this.openEventFn=this.open.bind(this),this.keyEventFn=this.onKeyPress.bind(this),this.bodyFocusEventFn=this.onBodyFocus.bind(this),function(e,i,s){for(var n in s)e.options[n]=s[n];if(e.el)if(void 0===e.el.length)if(e.options.interval>60)console.warn("appointment-picker: the maximal interval is 60");else{e.options.disabled.forEach(function(i,s){e.disabledArr[s]=t(e.options.disabled[s])});for(var r=0;r<60/e.options.interval;r++)e.intervals[r]=r*e.options.interval;e.options.static?(e.picker=e.build(),e.picker.classList.add("is-position-static"),e.picker.addEventListener("click",e.selectionEventFn),e.isInDom=!0):i.addEventListener("focus",e.openEventFn),e.setTime(e.el.value),i.addEventListener("keyup",e.keyEventFn),i.addEventListener("change",e.changeEventFn)}else console.warn("appointment-picker: pass only one dom element as argument")}(this,e,i||{})};return n.prototype.render=function(){if(this.isOpen){var e=this.el.offsetTop+this.el.offsetHeight,t=this.el.offsetLeft,i=this.picker.querySelector("input.is-selected");if(this.picker.classList.add("is-open"),i&&i.classList.remove("is-selected"),this.time.hasOwnProperty("h")){var s=this.picker.querySelector('[value="'+this.displayTime+'"]');s&&s.classList.add("is-selected")}this.picker.style.top=e+"px",this.picker.style.left=t+"px"}else this.picker.classList.remove("is-open")},n.prototype.open=function(){var e=this;this.isOpen||(this.isInDom||(this.picker=this.build(),this.isInDom=!0),this.isOpen=!0,this.render(),this.picker.addEventListener("click",this.selectionEventFn),this.picker.addEventListener("keyup",this.keyEventFn),setTimeout(function(){document.body.addEventListener("click",e.closeEventFn),document.body.addEventListener("focus",e.bodyFocusEventFn,!0)},100))},n.prototype.close=function(e){if(this.isOpen){if(Element.prototype.matches||(Element.prototype.matches=Element.prototype.msMatchesSelector||Element.prototype.webkitMatchesSelector),e){var t=e.target;if(t.isEqualNode(this.el))return;for(;t;){if(t.matches(".appo-picker"))return;t=t.parentElement}}this.isOpen=!1,this.render(),this.picker.removeEventListener("click",this.selectionEventFn),this.picker.removeEventListener("keyup",this.keyEventFn),document.body.removeEventListener("click",this.closeEventFn),document.body.removeEventListener("focus",this.bodyFocusEventFn,!0)}},n.prototype.select=function(e){var t=this;e.target.value&&(this.setTime(e.target.value),this.el.focus(),setTimeout(function(){t.close(null)},100))},n.prototype.onchange=function(e){this.setTime(this.el.value)},n.prototype.onKeyPress=function(e){var t=this.picker.querySelector('input[type="button"]:not([disabled])'),i=this.picker.querySelector("input.is-selected"),n=null;switch(e.keyCode){case 13:case 27:this.close(null);break;case 38:n=i?s(i.parentNode,-1):t.parentNode;break;case 40:n=i?s(i.parentNode,1):t.parentNode}n&&!n.firstChild.disabled&&(n.firstChild.classList.add("is-selected"),i&&i.classList.remove("is-selected"),this.setTime(n.firstChild.value))},n.prototype.onBodyFocus=function(e){this.isOpen&&this.close(e)},n.prototype.build=function(){var t=document.createElement("div");return t.innerHTML=function(t,s,n,r){for(var o=t.startTime,l=t.endTime,a="",c="12h"===t.mode,p=c?s.time12:s.time24,h=o;h { + pickerInstance = new AppointmentPicker(document.getElementById('time-spec'), { + interval: 30, + mode: '12h', + maxTime: 18, + startTime: 09, + endTime: 21, + disabled: ['1:30 pm', '2:00 pm', '5:30 pm'] + }); + + // Give focus to the input and therefore creating the picker dom elements + pickerInstance.el.focus(); + + console.log('pickerInstance =>', pickerInstance); + }); + + describe("initial value recognition", function() { + + it("applies the initial input value correctly as time", function() { + assert.isNumber(pickerInstance.getTime().h); + assert.isNumber(pickerInstance.getTime().m); + assert.equal(pickerInstance.getTime().h, 14); + assert.equal(pickerInstance.getTime().m, 30); + }); + + }); + + describe("dom element creation", function() { + + this.retries(4); + + it("creates the dom element after a focusin", function() { + assert.isNotNull(pickerInstance.picker); + assert.isNotNull(pickerInstance.picker.innerHTML); + }); + + it("opens the picker after a focusin", function() { + assert.include(pickerInstance.picker.classList.toString(), 'appo-picker'); + assert.include(pickerInstance.picker.classList.toString(), 'is-open'); + }); + + }); + + describe("time manipulation and parsing", function() { + + var testsAccept = [ + { args: " 10:00AM", expected: { h: 10, m: 0 } }, + { args: " 10:30 ", expected: { h: 10, m: 30 } }, + { args: "11:00 foo", expected: { h: 11, m: 0 } }, + { args: "12:00 pm", expected: { h: 12, m: 0 } }, + { args: "1:00pm", expected: { h: 13, m: 0 } }, + { args: "4:30 PM", expected: { h: 16, m: 30 } }, + { args: "18:00", expected: { h: 18, m: 0 } }, + { args: "12:00am", expected: { h: 0, m: 0 } } + ]; + + testsAccept.forEach(function(test) { + it('correctly parses "' + test.args + '" into time ' + JSON.stringify(test.expected), function(done) { + this.slow(500); + pickerInstance.setTime(test.args); + pickerInstance.render(); + var result = pickerInstance.getTime(); + assert.deepEqual(result, test.expected); + setTimeout(done, 100); + }); + }); + + }); + + describe("time validation and rejection", function() { + + before(() => { + pickerInstance.setTime("18:00"); + }); + + var testsReject = [ + { args: "20:00", expected: { h: 18, m: 0 } }, + { args: "8:30 pm", expected: { h: 18, m: 0 } }, + { args: "1:30 PM", expected: { h: 18, m: 0 } }, + { args: "2:00PM", expected: { h: 18, m: 0 } }, + { args: "dh4kj6", expected: { h: 18, m: 0 } } + ]; + + testsReject.forEach(function(test) { + it('rejects "' + test.args + '" and kepps its old value of 18:00', function() { + pickerInstance.setTime(test.args); + var result = pickerInstance.getTime(); + assert.deepEqual(result, test.expected); + }); + }); + + }); + + describe("exposed behaviour functions", function() { + + before(() => { + pickerInstance.setTime("12:00"); + pickerInstance.render(); + }); + + it("closes the picker", function(done) { + this.slow(1000); + pickerInstance.close(); + assert.notInclude(pickerInstance.picker.classList.toString(), 'is-open'); + setTimeout(done, 300); + }); + + it("opens the picker", function(done) { + this.slow(500); + pickerInstance.open(); + assert.include(pickerInstance.picker.classList.toString(), 'is-open'); + setTimeout(done, 100); + }); + + it("moves time selection by calling keyboard event function", function(done) { + this.slow(500); + // Simulate event with keyCode 40 = down arrow + pickerInstance.onKeyPress({ keyCode: 40 }); + var result = pickerInstance.getTime(); + assert.deepEqual(result, { h: 12, m: 30 }); + setTimeout(done, 100); + }); + + it("moves selection and skips disabled times using keyboard event function", function(done) { + this.slow(500); + // Simulate two events with keyCode 40 = down arrow + pickerInstance.onKeyPress({ keyCode: 40 }); + pickerInstance.onKeyPress({ keyCode: 40 }); + + var result = pickerInstance.getTime(); + assert.deepEqual(result, { h: 14, m: 30 }); + setTimeout(done, 100); + }); + }); +}); \ No newline at end of file diff --git a/tests/tests.html b/tests/tests.html new file mode 100644 index 0000000..0be6803 --- /dev/null +++ b/tests/tests.html @@ -0,0 +1,40 @@ + + + + + + + + + + + + + +
    + +
    + + +
    + + + + + +
    + + + + +

    Copyright Jan Suwart, MIT license

    +
    + + + \ No newline at end of file