ES6: port datepicker and util to ES6

Change-Id: I203aa47229e6c4dacd3a33bea39c512f4576a182
diff --git a/Gruntfile.js b/Gruntfile.js
index 106e241..34cdbba 100644
--- a/Gruntfile.js
+++ b/Gruntfile.js
@@ -144,6 +144,11 @@
 	          timestamp: true
 	        },
           {
+            src: 'public/css/kalamar-<%= pkg.version %>.css',
+            dest: 'public/css/kalamar-latest.css',
+            timestamp: true
+          },
+          {
 	          src: 'public/css/kalamar-plugin-<%= pkg.pluginVersion %>.css',
 	          dest: 'public/css/kalamar-plugin-latest.css',
 	          timestamp: true
@@ -159,7 +164,7 @@
     watch: {
       css: {
 	      files: ['dev/scss/**/*.scss'],
-	      tasks: ['sass'],
+        tasks: ['sass'],
 	      options: {
 	        spawn: false
 	      }
diff --git a/dev/js/src-lib/datepicker/datepicker.js b/dev/js/src-lib/datepicker/datepicker.js
new file mode 100644
index 0000000..859bd75
--- /dev/null
+++ b/dev/js/src-lib/datepicker/datepicker.js
@@ -0,0 +1,543 @@
+/**
+ * Date picker for the
+ * Virtual Collection builder.
+ *
+ * @author Nils Diewald
+ */
+
+import KorAP from '../util/util.js';
+
+const validDateMatchRE = new RegExp("^(?:[lg]?eq|ne)$");
+const validDateRE      = new RegExp("^(?:\\d{4})(?:-\\d\\d(?:-\\d\\d)?)?$");
+
+KorAP._validDateMatchRE = validDateMatchRE;
+KorAP._validDateRE      = validDateRE;
+
+/*
+ * Localizations
+ */
+const loc = KorAP.Locale;
+loc.WDAY = loc.WDAY || [
+  'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'
+];
+loc.MONTH = loc.MONTH || [
+  'January', 'February', 'March', 'April',
+  'May', 'June', 'July', 'August',
+  'September', 'October', 'November',
+  'December'
+];
+
+const d = document;
+
+// The datepicker class
+class KalamarDatepicker {
+
+    // Init datepicker
+    constructor() {
+      this._selected = [];
+    }
+
+
+    /**
+     * Get or select a specific date.
+     */
+    select(year, month, day) {
+      const t = this;
+      if (arguments.length >= 1) {
+        t._selected = {'year' : year};
+        t._showYear = year;
+        if (arguments.length >= 2) {
+          t._selected['month'] = month;
+          t._showMonth = month;
+          if (arguments.length >= 3) {
+            t._selected['day'] = day;
+            t._showDay = day;
+          };
+        };
+
+        return t;
+      };
+
+      return t._selected;
+    }
+
+
+    /**
+     * Select a specific date and
+     * init the accompanied action.
+     */
+    set(year, month, day) {
+      this.select(year, month, day);
+      this._store();
+    }
+
+
+    // Store the selected value
+    _store() {
+      if (this._click !== undefined)
+        this._click(this._selected);
+      else 
+        console.dir(this._selected);
+    }
+
+    
+    /**
+     * Set the action for clicking as a callback.
+     * The callback will retrieve a an object with
+     * an optional year attribute,
+     * an optional month attribute,
+     * and an optional day attribute
+     */
+    onclick(cb) {
+      this._click = cb;
+    }
+
+
+    /**
+     * The associated input field.
+     */
+    input() {
+      return this._input;
+    }
+    
+
+    /**
+     * Show the datepicker.
+     * Will either show the selected year/month
+     * or the current date.
+     * Will return the element for appending to the dom.
+     */
+    show(year, month) {
+
+      const e = this._el = d.createElement('div');
+      e.setAttribute('tabindex', 0);
+      e.style.outline = 0;
+      e.classList.add('datepicker');
+      
+      const today = new Date();
+      const t = this;
+
+      // Show year
+      t._showYear = (year !== undefined) ? year :
+        (t._selected['year'] ? this._selected['year'] :
+         today.getYear() + 1900);
+
+      // Show month
+      t._showMonth = month ? month :
+        (t._selected['month'] ? t._selected['month'] :
+         (today.getMonth() + 1));
+
+      // Append all helpers
+      e.appendChild(t._monthHelper());
+      e.appendChild(t._yearHelper());
+      e.appendChild(t._dayHelper());
+      t._input = e.appendChild(t._stringHelper());
+
+      // Always focus
+      e.addEventListener(
+        'mousedown',
+        function (ev) {
+          this._inField = true
+        }.bind(t)
+      );
+
+      e.addEventListener(
+        'mouseup',
+        function (ev) {
+          this._inField = false;
+          this._input.focus();
+        }.bind(t)
+      );
+
+      t._input.addEventListener(
+        'blur',
+        function (ev) {
+          if (!this._inField) {
+            if (this.fromString(this._input.value)) {
+              this._store();
+            };
+          };
+          ev.halt();
+        }.bind(t)
+      );
+
+      t._input.focus();
+
+      return t._el;
+    }
+
+    _stringHelper() {
+
+      // Create element
+      // Add input field
+      const input = d.createElement('input');
+      input.value = this.toString();
+      input.setAttribute('tabindex', 0);
+
+      input.addEventListener(
+        'keyup',
+        function (e) {
+          if (this.fromString(input.value)) {
+            this._updateYear();
+            this._updateMonth();
+            this._updateDay();
+          };
+        }.bind(this)
+      );
+
+      input.addEventListener(
+        'keypress',
+        function (e) {
+          if (e.keyCode == 13) {
+            if (this.fromString(input.value))
+              this._store();
+
+            e.halt();
+            return false;
+          }
+        }.bind(this)
+      )
+
+      return input;
+    }
+
+
+    /**
+     * Get the HTML element associated with the datepicker.
+     */
+    element() {
+      return this._el;
+    }
+
+
+    /**
+     * Get the current date in string format.
+     */
+    today() {
+      const today = new Date();
+      let str = today.getYear() + 1900;
+      const m = today.getMonth() + 1;
+      const d = today.getDate();
+      str += '-' + (m < 10 ? '0' + m : m);
+      str += '-' + (d < 10 ? '0' + d : d);
+      return str;
+    }
+
+
+    /**
+     * Stringification
+     */
+    toString() {
+      // There are values selected
+      let v = '';
+      const s = this._selected;
+      if (s['year']) {
+        v += s['year'];
+        if (s['month']) {
+          v += '-';
+          v += s['month'] < 10 ? '0' + s['month'] : s['month'];
+          if (s['day']) {
+            v += '-';
+            v += s['day'] < 10 ? '0' + s['day'] : s['day'];
+          };
+        };
+      };
+      return v;
+    }
+
+
+    /**
+     * Increment the year.
+     */
+    incrYear() {
+      const t = this;
+      if (t._showYear < 9999) {
+        t._showYear++;
+        t._updateYear();
+        t._updateMonth();
+        t._updateDay();
+        return t;
+      };
+      return;
+    }
+
+
+    /**
+     * Decrement the year.
+     */
+    decrYear() {
+      const t = this;
+      if (t._showYear > 0) {
+        t._showYear--;
+        t._updateYear();
+        t._updateMonth();
+        t._updateDay();
+        return t;
+      };
+      return;
+    }
+
+
+    /**
+     * Increment the month.
+     */
+    incrMonth() {
+      const t = this;
+      t._showMonth++;
+      if (t._showMonth > 12) {
+        t._showMonth = 1;
+        t.incrYear();
+      }
+      else {
+        t._updateMonth();
+        t._updateDay();
+      };
+      return t;
+    }
+
+
+    /**
+     * Decrement the month.
+     */
+    decrMonth() {
+      const t = this;
+      t._showMonth--;
+      if (t._showMonth < 1) {
+        t._showMonth = 12;
+        t.decrYear();
+      }
+      else {
+        t._updateMonth();
+        t._updateDay();
+      };
+
+      return t;
+    }
+
+
+    // Create the year helper element.
+    _yearHelper() {
+      const t = this;
+      const year = d.createElement('div');
+      year.classList.add('year');
+
+      // Decrement year
+      year.addE('span')
+        .onclick = t.decrYear.bind(t);
+
+      t._yElement = year.addE('span');
+      t._yElement.addT(t._showYear);
+
+      t._yElement.onclick = function () {
+        t.set(t._showYear);
+      }.bind(t);
+      t._selectYear();
+
+      // Increment year
+      year.addE('span')
+        .onclick = t.incrYear.bind(t);
+
+      return year;
+    }
+
+
+    // Update the year helper view.
+    _updateYear() {
+      this._yElement.firstChild.data = this._showYear;
+      this._selectYear();
+    }
+
+
+    // Check if the viewed year is current
+    _selectYear() {
+      if (this._showYear === this.select()['year'])
+        this._yElement.classList.add('selected');
+      else
+        this._yElement.classList.remove('selected');
+    }
+
+
+    // Create the month helper element.
+    _monthHelper() {
+      const t = this;
+      const month = d.createElement('div');
+      month.classList.add('month');
+
+      // Decrement month
+      month.addE('span')
+        .onclick = t.decrMonth.bind(t);
+      
+      t._mElement = month.addE('span');
+      t._mElement.addT(loc.MONTH[t._showMonth-1]);
+      t._mElement.onclick = function () {
+        this.set(this._showYear, this._showMonth);
+      }.bind(t);
+
+      t._selectMonth();
+      
+      // Increment month
+      month.addE('span')
+        .onclick = t.incrMonth.bind(t);
+
+      return month;
+    }
+
+    // Update the month helper view.
+    _updateMonth() {
+      if (this._showMonth === undefined || this._showMonth > 12)
+        this._showMonth = 1;
+
+      this._mElement.firstChild.data = loc.MONTH[this._showMonth-1];
+      this._selectMonth();
+    }
+
+
+    // Check if the viewed month is current
+    _selectMonth() {
+      const t = this;
+      if (t._showYear === t.select()['year'] &&
+          t._showMonth === t.select()['month'])
+        t._mElement.classList.add('selected');
+      else
+        t._mElement.classList.remove('selected');
+    }
+
+
+    // Create the day (calendar) helper element.
+    _dayHelper() {
+      const table = d.createElement('table');
+
+      // Localized day view
+      const tr = table.addE('thead').addE('tr');
+      for (let i = 0; i < 7; i++) {
+        tr.addE('th').addT(loc.WDAY[i]);
+      };
+
+      this._dBElement = this._dayBody();
+
+      table.appendChild(this._dBElement);
+      return table;
+    }
+
+
+    // Create day body for calendar table
+    _dayBody() {
+      const showDate = new Date(
+        this._showYear,
+        this._showMonth - 1,
+        1,
+        0,
+        0,
+        0,
+        0
+      );
+      const date = new Date(
+        this._showYear,
+        this._showMonth - 1,
+        1,
+        0,
+        0,
+        0,
+        0
+      );
+      const today = new Date();
+      const that = this;
+
+      // What happens, in case someone clicks
+      // on a date
+      const click = function () {
+        that.set(
+          that._showYear,
+          that._showMonth,
+          parseInt(this.firstChild.data)
+        );
+      };
+
+      // Skip back to the previous monday (may be in the last month)
+      date.setDate(date.getDate() - ((date.getDay() + 6) % 7));
+
+      const tb = d.createElement('tbody');
+
+      const s = this.select();
+      
+      let tr, i, td;
+
+      // Iterate over all days of the table
+      while (1) {
+
+        // Loop through the week
+        tr = tb.addE('tr');
+        for (i = 0; i < 7; i++) {
+          td = tr.addE('td');
+          
+          // Not part of the current month
+          if (date.getMonth() !== showDate.getMonth()) {
+            td.classList.add('out');
+          }
+          else {
+            td.onclick = click;
+          };
+    
+          // This is the current day
+          if (date.getDate()     === today.getDate() &&
+              date.getMonth()    === today.getMonth() &&
+              date.getFullYear() === today.getFullYear()) {
+            td.classList.add('today');
+          };
+
+          // This is the day selected
+          if (s && s['day']) {
+            if (date.getDate()     === s['day'] &&
+                date.getMonth()    === s['month']-1 &&
+                date.getFullYear() === s['year']) {
+              td.classList.add('selected');
+            };
+          };
+          
+          // Add the current day to the table
+          td.addT(date.getDate());
+    
+          // Next day
+          date.setDate(date.getDate() + 1);
+        };
+        
+        if (date.getMonth() !== showDate.getMonth())
+          break;
+      };
+      return tb;
+    }
+
+    // Update the calendar view
+    _updateDay() {
+      const newBody = this._dayBody();
+      this._dBElement.parentNode.replaceChild(
+        newBody,
+        this._dBElement
+      );
+      this._dBElement = newBody;
+    }
+
+
+    /**
+     * Parse date from string.
+     */
+    fromString(v) {
+      if (v === undefined)
+        return false;
+
+      if (!KorAP._validDateRE.test(v))
+        return false;
+
+      const d = v.split('-', 3);
+      d[0] = parseInt(d[0]);
+      if (d[1]) d[1] = parseInt(d[1]);
+      if (d[2]) d[2] = parseInt(d[2]);
+
+      // Select values
+      this.select(d[0], d[1], d[2]);
+      return true;
+    }
+}
+
+export default KalamarDatepicker;
+export { validDateMatchRE, validDateRE };
diff --git a/dev/js/src-lib/datepicker/demo/datepickerdemo.js b/dev/js/src-lib/datepicker/demo/datepickerdemo.js
new file mode 100644
index 0000000..eeafd6f
--- /dev/null
+++ b/dev/js/src-lib/datepicker/demo/datepickerdemo.js
@@ -0,0 +1,18 @@
+import KalamarDatepicker from '../datepicker.js';
+import '../../../../scss/kalamar.scss';
+
+function mountDatepicker() {
+  const host = document.getElementById('dp');
+  if (!host) {
+    return;
+  }
+
+  const dp = new KalamarDatepicker();
+  host.appendChild(dp.select(2015, 4, 12).show(2015, 4));
+}
+
+if (document.readyState === 'loading') {
+  document.addEventListener('DOMContentLoaded', mountDatepicker);
+} else {
+  mountDatepicker();
+}
diff --git a/dev/js/src-lib/datepicker/demo/index.html b/dev/js/src-lib/datepicker/demo/index.html
new file mode 100644
index 0000000..0ac7e0c
--- /dev/null
+++ b/dev/js/src-lib/datepicker/demo/index.html
@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html>
+<head>
+  <title>Demo: Datepicker</title>
+</head>
+<body>
+  <h1>Datepicker</h1>
+  <script type="module" src="./datepickerdemo.js"></script>
+    <div class="vc">
+      <div id="dp"></div>
+    </div>
+</body>
+</html>
diff --git a/dev/js/src-lib/datepicker/package.json b/dev/js/src-lib/datepicker/package.json
new file mode 100644
index 0000000..e74fb87
--- /dev/null
+++ b/dev/js/src-lib/datepicker/package.json
@@ -0,0 +1,11 @@
+{
+  "name": "kalamar-datepicker",
+  "version": "0.0.1",
+  "description": "Datepicker for Kalmar",
+  "main": "datepicker.js",
+  "scripts": {
+    "dev": "vite --open /demo/index.html"
+  },
+  "author": "",
+  "license": "ISC"
+}
diff --git a/dev/js/src-lib/datepicker/test/datepickerSpec.js b/dev/js/src-lib/datepicker/test/datepickerSpec.js
new file mode 100644
index 0000000..711fd45
--- /dev/null
+++ b/dev/js/src-lib/datepicker/test/datepickerSpec.js
@@ -0,0 +1,176 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+let KalamarDatepicker;
+
+describe('KorAP.Datepicker', function () {
+
+    beforeAll(async () => {
+      const module = await import('../datepicker.js');
+      KalamarDatepicker = module.default;
+    });
+
+    it('should be initializable', function () {
+      var dp = new KalamarDatepicker();
+      var e = dp.show();
+      expect(e.nodeName).toEqual('DIV');
+      expect(e.classList.contains('datepicker')).toBeTruthy();
+      expect(e.getAttribute('tabindex')).toEqual('0');
+    });
+
+    it('should generate valid dates', function () {
+      var dp = new KalamarDatepicker();
+      expect(dp.today()).toMatch(/^\d{4}-(?:0[1-9]|1[0-2])-(?:0[1-9]|[12]\d|3[01])$/);
+    });    
+
+    it('should have year and month helpers', function () {
+      var dp = new KalamarDatepicker();
+      var e = dp.show(2013, 2);
+      expect(e.nodeName).toEqual('DIV');
+      expect(e.classList.contains('datepicker')).toBeTruthy();
+      expect(e.getAttribute('tabindex')).toEqual('0');
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2013');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+    });
+
+    it('should have modifyable year', function () {
+      var dp = new KalamarDatepicker();
+      var e = dp.show(2013, 2);
+      expect(e.nodeName).toEqual('DIV');
+      expect(e.classList.contains('datepicker')).toBeTruthy();
+      expect(e.getAttribute('tabindex')).toEqual('0');
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2013');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.incrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2014');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.incrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2015');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.decrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2014');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      // Max value
+      e = dp.show(9998, 2);
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('9998');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.incrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('9999');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.incrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('9999');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      // Min value
+      e = dp.show(2, 2);
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.decrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('1');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.decrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('0');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.decrYear();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('0');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+    });
+
+    it('should have modifyable month', function () {
+      var dp = new KalamarDatepicker();
+      var e = dp.show(2012, 9);
+
+      expect(e.nodeName).toEqual('DIV');
+      expect(e.classList.contains('datepicker')).toBeTruthy();
+
+      expect(e.getAttribute('tabindex')).toEqual('0');
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2012');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('September');
+
+      dp.incrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2012');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('October');
+
+      dp.incrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2012');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('November');
+
+      dp.incrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2012');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('December');
+
+      dp.incrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2013');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('January');
+
+      dp.decrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2012');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('December');
+
+      // Max value
+      e = dp.show(9999, 12);
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('9999');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('December');
+
+      dp.incrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('9999');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('December');
+
+      // Min value
+      e = dp.show(1, 2);
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('1');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.decrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('1');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('January');
+
+      dp.decrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('0');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('December');
+
+      e = dp.show(0, 2);
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('0');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('February');
+
+      dp.decrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('0');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('January');
+
+      dp.decrMonth();
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('0');
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('January');
+
+    });
+
+    it('should parse from string', function () {
+      var dp = new KalamarDatepicker();
+      expect(dp.fromString()).toBeFalsy();
+      expect(dp.fromString("2020-September")).toBeFalsy();
+      expect(dp.fromString("2020")).toBeTruthy();
+      expect(dp.fromString("2020-10")).toBeTruthy();
+      expect(dp.fromString("2020-10-9")).toBeFalsy();
+      expect(dp.fromString("2020-10-09")).toBeTruthy();
+
+      expect(dp._selected['year']).toEqual(2020);
+      expect(dp._selected['month']).toEqual(10);
+      expect(dp._selected['day']).toEqual(9);
+
+      var e = dp.show(2020, 11);
+      
+      expect(e.querySelector('div.year > span:nth-child(2)').firstChild.data).toEqual('2020');
+      expect(e.querySelector('div.year > span:nth-child(2)').classList.contains('selected')).toBeTruthy();
+      expect(e.querySelector('div.month > span:nth-child(2)').firstChild.data).toEqual('November');
+      expect(e.querySelector('div.month > span:nth-child(2)').classList.contains('selected')).toBeFalsy();
+      expect(e.querySelector('div.month > span:nth-child(2)').classList.contains('selected')).toBeFalsy();
+
+      expect(dp.toString()).toEqual("2020-10-09");
+    });
+});
diff --git a/dev/js/src-lib/datepicker/vite.config.js b/dev/js/src-lib/datepicker/vite.config.js
new file mode 100644
index 0000000..1edc2f0
--- /dev/null
+++ b/dev/js/src-lib/datepicker/vite.config.js
@@ -0,0 +1,29 @@
+import { defineConfig } from 'vite';
+import path from 'path';
+
+const scssDir = path.resolve(__dirname, '../../../scss'); // Path to your SCSS directory
+const fontDir = path.resolve(__dirname, '../../../font');
+
+export default defineConfig({
+
+  server: {
+    fs: {
+      allow: [
+        scssDir,
+        fontDir,
+        __dirname
+      ]
+    },
+    port: 3000
+  },
+
+ 
+  css: {
+    preprocessorOptions: {
+      scss: {
+        loadPaths: [scssDir]
+      }
+    }
+  }
+});
+
diff --git a/dev/js/src-lib/util/test/utilSpec.js b/dev/js/src-lib/util/test/utilSpec.js
new file mode 100644
index 0000000..f7bcf54
--- /dev/null
+++ b/dev/js/src-lib/util/test/utilSpec.js
@@ -0,0 +1,66 @@
+import { describe, it, expect, beforeAll } from 'vitest';
+
+let initTogglePwdVisibility;
+let initCopyToClipboard;
+
+beforeAll(async function () {
+  const utilModule = await import('../util.js');
+  initTogglePwdVisibility = utilModule.initTogglePwdVisibility;
+  initCopyToClipboard = utilModule.initCopyToClipboard;
+});
+
+describe('KorAP.util', function () {
+
+    it('should quote', function () {
+      expect('Baum'.quote()).toEqual('"Baum"');
+      expect('B"a"um'.quote()).toEqual('"B\\"a\\"um"');
+    });
+
+    it('should escape regex', function () {
+      expect('aaa/bbb\/ccc'.escapeRegex()).toEqual('aaa\\/bbb\\/ccc');
+    });
+
+    it('should slugify', function () {
+      expect('/korap/test'.slugify()).toEqual('koraptest');
+      expect('korap test'.slugify()).toEqual('korap-test');
+      expect('Korap Test'.slugify()).toEqual('korap-test');
+    });
+});
+
+describe('KorAP.util.initTogllePwdVisibility', function () {
+    it('should toggle', function () {
+        const div = document.createElement('div');
+        let input = div.addE('input');
+        input.setAttribute('type', 'password');
+        input.setAttribute('class', 'show-pwd');
+
+        expect(div.children.length).toEqual(1);
+        initTogglePwdVisibility(div);
+        expect(div.children.length).toEqual(2);
+        expect(div.lastChild.tagName).toEqual("A");
+        expect(div.lastChild.classList.contains("hide")).toBeFalsy();
+        expect(input.getAttribute("type")).toEqual("password");
+
+        div.lastChild.click();
+
+        expect(input.getAttribute("type")).toEqual("text");
+        expect(div.lastChild.classList.contains("hide")).toBeTruthy();
+    });
+});
+
+describe('KorAP.util.initCopyToClipboard', function () {
+  it('should be initializable', function () {
+      const div = document.createElement('div');
+      let input = div.addE('input');
+      input.value = "abcde";
+      input.setAttribute('type', 'text');
+      input.setAttribute('class', 'copy-to-clipboard');
+      expect(div.children.length).toEqual(1);
+      initCopyToClipboard(div);
+      expect(div.children.length).toEqual(2);
+      expect(div.lastChild.tagName).toEqual("A");
+  });
+
+  // document.execCommand() can't be tested without user
+  // intervention.
+});
diff --git a/dev/js/src-lib/util/util.js b/dev/js/src-lib/util/util.js
new file mode 100644
index 0000000..3a8c65e
--- /dev/null
+++ b/dev/js/src-lib/util/util.js
@@ -0,0 +1,230 @@
+window.KorAP = window.KorAP || {};
+const KorAP = window.KorAP;
+
+// Don't let events bubble up
+if (Event.halt === undefined) {
+  // Don't let events bubble up
+  Event.prototype.halt = function () {
+    this.stopPropagation();
+    this.preventDefault();
+  };
+};
+
+const _quoteRE = new RegExp("([\"\\\\])", 'g');
+String.prototype.quote = function () {
+  return '"' + this.replace(_quoteRE, '\\$1') + '"';
+};
+
+const _escapeRE = new RegExp("([\/\\\\])", 'g');
+String.prototype.escapeRegex = function () {
+  return this.replace(_escapeRE, '\\$1');
+};
+
+const _slug1RE = new RegExp("[^-a-zA-Z0-9_\\s]+", 'g');
+const _slug2RE = new RegExp("[-\\s]+", 'g');
+String.prototype.slugify = function () {
+  return this.toLowerCase().replace(_slug1RE, '').replace(_slug2RE, '-');
+};
+
+/**
+ * Upgrade this object to another object,
+ * while private data stays intact.
+ *
+ * @param {Object} An object with properties.
+ */
+Object.defineProperty(Object.prototype, 'upgradeTo', {
+  value: function (props) {
+    for (let prop in props) {
+      this[prop] = props[prop];
+    };
+    return this;
+  },
+  enumerable: false,
+  configurable: true,
+  writable: true
+});
+
+
+// Add toggleClass method similar to jquery
+HTMLElement.prototype.toggleClass = function (c1, c2) {
+  const cl = this.classList;
+  if (cl.contains(c1)) {
+    cl.add(c2);
+    cl.remove(c1);
+  }
+  else {
+    cl.remove(c2);
+    cl.add(c1);
+  };
+};
+
+// Append element by tag name
+HTMLElement.prototype.addE = function (tag) {
+  return this.appendChild(document.createElement(tag));
+};
+
+// Append text node
+HTMLElement.prototype.addT = function (text) {
+  return this.appendChild(document.createTextNode(text));
+};
+
+
+// Utility for removing all children of a node
+function _removeChildren (node) {
+  // Remove everything underneath
+  while (node.firstChild)
+    node.removeChild(node.firstChild);
+};
+
+
+// Utility to get either the charCode
+// or the keyCode of an event
+function _codeFromEvent (e) {
+  if ((e.charCode) && (e.keyCode==0))
+    return e.charCode
+  return e.keyCode;
+};
+
+function _dec2hex (dec) {
+  return ('0' + dec.toString(16)).substr(-2)
+};
+
+
+/**
+ * Create random identifiers
+ */
+/*
+ * code based on
+ * https://stackoverflow.com/questions/1349404/generate-random-string-characters-in-javascript#8084248
+ */
+function randomID (len) {
+  const arr = new Uint8Array((len || 40) / 2)
+  window.crypto.getRandomValues(arr)
+  return Array.from(arr, _dec2hex).join('')
+};
+
+
+/**
+ * Add option to show passwords.
+ */
+function initTogglePwdVisibility (element) {
+    const el = element.querySelectorAll("input[type=password].show-pwd");
+    for (let x = 0; x < el.length; x++) {     
+        const pwd = el[x];
+
+        const a = document.createElement('a');
+        a.classList.add('show-pwd');         
+        a.addEventListener('click', function () {
+            if (pwd.getAttribute("type") === "password") {
+                pwd.setAttribute("type", "text");
+                a.classList.add('hide');
+                return;
+            };
+            pwd.setAttribute("type", "password");
+            a.classList.remove('hide');
+        });
+        pwd.parentNode.insertBefore(a, pwd.nextSibling);
+    };
+};
+
+
+/**
+ * Add option to copy to clipboard.
+ */
+function initCopyToClipboard (element) {
+    const el = element.querySelectorAll("input.copy-to-clipboard");
+    for (let x = 0; x < el.length; x++) {     
+        const text = el[x];
+        const a = document.createElement('a');
+        a.classList.add('copy-to-clipboard');         
+        a.addEventListener('click', function () {
+            let back = false;
+            if (text.getAttribute("type") === 'password') {
+                text.setAttribute("type", "text");
+                back = true;
+            };
+            text.select();
+            text.setSelectionRange(0, 99999);
+            document.execCommand("copy");
+            if (back) {
+                text.setAttribute("type", "password");
+            };
+        });
+        text.parentNode.insertBefore(a, text.nextSibling);
+    };
+};
+
+
+// Todo: That's double now!
+KorAP.API = KorAP.API || {};
+KorAP.Locale = KorAP.Locale || {};
+
+const loc = KorAP.Locale;
+loc.OR  = loc.OR  || 'or';
+loc.AND = loc.AND || 'and';
+
+// Add new stylesheet object lazily to document
+KorAP.newStyleSheet = function () {
+  if (KorAP._sheet === undefined) {
+    const sElem = document.createElement('style');
+    document.head.appendChild(sElem);
+    KorAP._sheet = sElem.sheet;
+  };
+  return KorAP._sheet;
+};
+
+
+// Default log message
+KorAP.log = KorAP.log || function (type, msg, src) {
+  if (src)
+    msg += ' from ' + src;
+  console.log(type + ": " + msg);
+};
+
+/**
+ * A Method for generating an array of nodes, that are direct descendants of the passed
+ * element node, using a tag tagName as a parameter. Supposed to be used by the specification only.
+ * @param {HTMLNode} element The HTMLNode / element object whose children we are fetching
+ * @param {String} tagName The tag the children are looked for by
+ * @returns An array of children nodes with tag tagName
+ */
+function directElementChildrenByTagName (element, tagName) {
+  const tagElementsCollection=element.getElementsByTagName(tagName);
+  //var tagElements = Array.from(tagElementsCollection);
+  //var tagElements = [...tagElementsCollection];
+  //This one has the best compatability:
+  var tagElements = Array.prototype.slice.call(tagElementsCollection);
+  //filter by actually being direct child node
+  tagElements = tagElements.filter(subElement => subElement.parentNode === element);
+  return tagElements;
+};
+
+/**
+ * A Method for generating an array of nodes, that are direct descendants of the passed
+ * element node, using a class className as a parameter. Supposed to be used by the specification only.
+ * @param {HTMLNode} element The HTMLNode / element object whose children we are fetching
+ * @param {String} className The class the children are looked for by
+ * @returns An array of children nodes with class className
+ */
+ function directElementChildrenByClassName (element, className) {
+  const classElementsCollection=element.getElementsByTagName(className);
+  //var classElements = Array.from(classElementsCollection);
+  //var classElements = [...classElementsCollection];
+  //This one has the best compatability:
+  var classElements = Array.prototype.slice.call(classElementsCollection);
+  //filter by actually being direct child node
+  classElements = classElements.filter(subElement => subElement.parentNode === element);
+  return classElements;
+};
+
+export {
+  _removeChildren as removeChildren,
+  _codeFromEvent as codeFromEvent,
+  randomID,
+  initTogglePwdVisibility,
+  initCopyToClipboard,
+  directElementChildrenByTagName,
+  directElementChildrenByClassName
+};
+
+export default KorAP;
\ No newline at end of file
diff --git a/package.json b/package.json
index 6b8fda6..bf2b976 100644
--- a/package.json
+++ b/package.json
@@ -4,8 +4,13 @@
   "license": "BSD-2-Clause",
   "version": "0.66.0",
   "pluginVersion": "0.2.3",
+  "scripts": {
+    "dev": "vite --config vite.config.js",
+    "build": "vite build --config vite.config.js",
+    "test": "vitest --config vitest.config.js --run"
+  },
   "engines": {
-    "node": ">=6.0.0"
+    "node": ">=20.19.0"
   },
   "repository": {
     "type": "git",
@@ -22,7 +27,10 @@
     "imagemin": "^9.0.1",
     "optipng-bin": "^9.0.0",
     "sass": "^1.99.0",
-    "vinyl-fs": "^4.0.2"
+    "vinyl-fs": "^4.0.2",
+    "vite": "^8.1.4",
+    "vitest": "^2.0.0",
+    "jsdom": "^24.0.0"
   },
   "optionalDependencies": {
     "fsevents": "*"
diff --git a/vite.config.js b/vite.config.js
new file mode 100644
index 0000000..7467b5b
--- /dev/null
+++ b/vite.config.js
@@ -0,0 +1,18 @@
+import { defineConfig } from 'vite';
+import path from 'path';
+
+const scssDir = path.resolve(__dirname, 'dev/scss'); // Path to your SCSS directory
+
+export default defineConfig({
+  css: {
+    preprocessorOptions: {
+      scss: {
+        loadPaths: [scssDir]
+      }
+    }
+  },
+  server: {
+    port: 3000
+  }
+});
+
diff --git a/vitest.config.js b/vitest.config.js
new file mode 100644
index 0000000..c63c71e
--- /dev/null
+++ b/vitest.config.js
@@ -0,0 +1,10 @@
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    root: 'dev/js/src-lib',
+    environment: 'jsdom',
+    include: ['*/test/**/*Spec.js'],
+    globals: true
+  }
+});