diff --git a/js/src/mainviewmodel.js b/js/src/mainviewmodel.js
new file mode 100644
index 0000000..645fcf5
--- /dev/null
+++ b/js/src/mainviewmodel.js
@@ -0,0 +1,79 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import ViewModel from './viewmodel.js';
+
+const hiddenClass = 'menu-hidden';
+
+export default class MainViewModel extends ViewModel {
+
+ /**
+ * @param {uState} state
+ */
+ constructor(state) {
+ super({
+ onMenuToggle: null,
+ onShowUserMenu: null
+ });
+ this.state = state;
+ this.model.onMenuToggle = () => this.toggleSideMenu();
+ this.model.onShowUserMenu = () => this.toggleUserMenu();
+ this.hideUserMenuCallback = (e) => this.hideUserMenu(e);
+ this.menuEl = document.querySelector('#menu');
+ this.userMenuEl = document.querySelector('#user-menu');
+ }
+
+ init() {
+ this.bindAll();
+ }
+
+ toggleSideMenu() {
+ if (this.menuEl.classList.contains(hiddenClass)) {
+ this.menuEl.classList.remove(hiddenClass);
+ } else {
+ this.menuEl.classList.add(hiddenClass);
+ }
+ }
+
+ /**
+ * Toggle user menu visibility
+ */
+ toggleUserMenu() {
+ if (this.userMenuEl.classList.contains(hiddenClass)) {
+ this.userMenuEl.classList.remove(hiddenClass);
+ window.addEventListener('click', this.hideUserMenuCallback, true);
+ } else {
+ this.userMenuEl.classList.add(hiddenClass);
+ }
+ }
+
+ /**
+ * Click listener callback to hide user menu
+ * @param {MouseEvent} event
+ */
+ hideUserMenu(event) {
+ const el = event.target;
+ this.userMenuEl.classList.add(hiddenClass);
+ window.removeEventListener('click', this.hideUserMenuCallback, true);
+ if (!el.parentElement.classList.contains('user-menu')) {
+ event.stopPropagation();
+ }
+ }
+
+}
diff --git a/js/test/mainviewmodel.test.js b/js/test/mainviewmodel.test.js
new file mode 100644
index 0000000..0863666
--- /dev/null
+++ b/js/test/mainviewmodel.test.js
@@ -0,0 +1,135 @@
+/*
+ * μlogger
+ *
+ * Copyright(C) 2019 Bartek Fabiszewski (www.fabiszewski.net)
+ *
+ * This is free software; you can redistribute it and/or modify it under
+ * the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but
+ * WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, see .
+ */
+
+import MainViewModel from '../src/mainviewmodel.js';
+import ViewModel from '../src/viewmodel.js';
+import uState from '../src/state.js';
+
+describe('MainViewModel tests', () => {
+
+ const hiddenClass = 'menu-hidden';
+ let vm;
+ let state;
+ let menuEl;
+ let userMenuEl;
+
+ beforeEach(() => {
+ const fixture = `
`;
+ document.body.insertAdjacentHTML('afterbegin', fixture);
+ menuEl = document.querySelector('#menu');
+ userMenuEl = document.querySelector('#user-menu');
+ spyOn(window, 'addEventListener');
+ spyOn(window, 'removeEventListener').and.callThrough();
+ state = new uState();
+ vm = new MainViewModel(state);
+ });
+
+ afterEach(() => {
+ document.body.removeChild(document.querySelector('#fixture'));
+ });
+
+ it('should create instance', () => {
+ expect(vm).toBeInstanceOf(ViewModel);
+ expect(vm.state).toBe(state);
+ expect(vm.menuEl).toBe(menuEl);
+ expect(vm.userMenuEl).toBe(userMenuEl);
+ });
+
+ it('should hide side menu', (done) => {
+ // given
+ const buttonEl = document.querySelector('#menu-button a');
+ vm.init();
+ // when
+ buttonEl.click();
+ // then
+ setTimeout(() => {
+ expect(menuEl.classList.contains(hiddenClass)).toBe(true);
+ done();
+ }, 100);
+ });
+
+ it('should show side menu', (done) => {
+ // given
+ const buttonEl = document.querySelector('#menu-button a');
+ menuEl.classList.add(hiddenClass);
+ vm.init();
+ // when
+ buttonEl.click();
+ // then
+ setTimeout(() => {
+ expect(menuEl.classList.contains(hiddenClass)).toBe(false);
+ done();
+ }, 100);
+ });
+
+ it('should hide user menu', (done) => {
+ // given
+ const buttonEl = document.querySelector('#user-menu-button');
+ userMenuEl.classList.remove(hiddenClass);
+ vm.init();
+ // when
+ buttonEl.click();
+ // then
+ setTimeout(() => {
+ expect(userMenuEl.classList.contains(hiddenClass)).toBe(true);
+ done();
+ }, 100);
+ });
+
+ it('should show user menu', (done) => {
+ // given
+ const buttonEl = document.querySelector('#user-menu-button');
+ vm.init();
+ // when
+ buttonEl.click();
+ // then
+ setTimeout(() => {
+ expect(userMenuEl.classList.contains(hiddenClass)).toBe(false);
+ expect(window.addEventListener).toHaveBeenCalledTimes(1);
+ expect(window.addEventListener).toHaveBeenCalledWith('click', vm.hideUserMenuCallback, true);
+ done();
+ }, 100);
+ });
+
+ it('should hide user menu on window click', (done) => {
+ // given
+ userMenuEl.classList.remove(hiddenClass);
+ window.addEventListener.and.callThrough();
+ window.addEventListener('click', vm.hideUserMenuCallback, true);
+ vm.init();
+ // when
+ document.body.click();
+ // then
+ setTimeout(() => {
+ expect(userMenuEl.classList.contains(hiddenClass)).toBe(true);
+ expect(window.removeEventListener).toHaveBeenCalledTimes(1);
+ expect(window.removeEventListener).toHaveBeenCalledWith('click', vm.hideUserMenuCallback, true);
+ done();
+ }, 100);
+ });
+
+});