x/tools/cmd/heapview: add a sidebar to hold navigation

This change also puts more structure into the viewer.
Adds an enum for events that we'll issue and a few more elements
to organize things.

Change-Id: I39c7c53422779348ca05f051c6b0b07d22ad6a00
Reviewed-on: https://go-review.googlesource.com/26656
Reviewed-by: Alan Donovan <adonovan@google.com>
diff --git a/cmd/heapview/client/main.ts b/cmd/heapview/client/main.ts
index 045e069..fe3985c 100644
--- a/cmd/heapview/client/main.ts
+++ b/cmd/heapview/client/main.ts
@@ -3,47 +3,193 @@
 // license that can be found in the LICENSE file.
 
 /**
- * A hamburger menu element.
+ * An enum of types of actions that might be requested
+ * by the app.
  */
-class HamburgerElement extends HTMLElement {
-  attachedCallback() {
-    this.innerHTML = '&#9776';  // Unicode character for hamburger menu.
+enum Action {
+  TOGGLE_SIDEBAR,  // Toggle the sidebar.
+  NAVIGATE_ABOUT,  // Go to the about page.
+}
+
+const TITLE = 'Go Heap Viewer';
+
+/**
+ * A type of event that signals to the AppElement controller
+ * that something shoud be done. For the most part, the structure
+ * of the app will be that elements' state will mostly be controlled
+ * by parent elements. Elements will issue actions that the AppElement
+ * will handle, and the app will be re-rendered down the DOM
+ * hierarchy.
+ */
+class ActionEvent extends Event {
+  static readonly EVENT_TYPE = 'action-event'
+  constructor(public readonly action: Action) { super(ActionEvent.EVENT_TYPE); }
+}
+
+/**
+ * A hamburger menu element. Triggers a TOGGLE_SIDE action to toggle the
+ * sidebar.
+ */
+export class HamburgerElement extends HTMLElement {
+  static readonly NAME = 'heap-hamburger';
+
+  createdCallback() {
+    this.appendChild(document.createTextNode('☰'));
+    this.onclick =
+        () => { this.dispatchEvent(new ActionEvent(Action.TOGGLE_SIDEBAR)) };
   }
 }
-document.registerElement('heap-hamburger', HamburgerElement);
+document.registerElement(HamburgerElement.NAME, HamburgerElement);
 
 /**
  * A heading for the page with a hamburger menu and a title.
  */
 export class HeadingElement extends HTMLElement {
-  attachedCallback() {
+  static readonly NAME = 'heap-heading';
+
+  createdCallback() {
     this.style.display = 'block';
     this.style.backgroundColor = '#2196F3';
     this.style.webkitUserSelect = 'none';
     this.style.cursor = 'default';
     this.style.color = '#FFFFFF';
     this.style.padding = '10px';
-    this.innerHTML = `
-      <div style="margin:0px; font-size:2em"><heap-hamburger></heap-hamburger> Go Heap Viewer</div>
-    `;
+
+    const div = document.createElement('div');
+    div.style.margin = '0px';
+    div.style.fontSize = '2em';
+    div.appendChild(document.createElement(HamburgerElement.NAME));
+    div.appendChild(document.createTextNode(' ' + TITLE));
+    this.appendChild(div);
   }
 }
-document.registerElement('heap-heading', HeadingElement);
+document.registerElement(HeadingElement.NAME, HeadingElement);
 
 /**
- * Reset body's margin and padding, and set font.
+ * A sidebar that has navigation for the app.
  */
-function clearStyle() {
-  document.head.innerHTML += `
-  <style>
-    * {font-family: Roboto,Helvetica}
-    body {margin: 0px; padding:0px}
-  </style>
-  `;
+export class SidebarElement extends HTMLElement {
+  static readonly NAME = 'heap-sidebar';
+
+  createdCallback() {
+    this.style.display = 'none';
+    this.style.backgroundColor = '#9E9E9E';
+    this.style.width = '15em';
+
+    const aboutButton = document.createElement('button');
+    aboutButton.innerText = 'about';
+    aboutButton.onclick =
+        () => { this.dispatchEvent(new ActionEvent(Action.NAVIGATE_ABOUT)) };
+    this.appendChild(aboutButton);
+  }
+
+  toggle() {
+    this.style.display = this.style.display === 'none' ? 'block' : 'none';
+  }
+}
+document.registerElement(SidebarElement.NAME, SidebarElement);
+
+/**
+ * A Container for the main content in the app.
+ * TODO(matloob): Implement main content.
+ */
+export class MainContentElement extends HTMLElement {
+  static readonly NAME = 'heap-container';
+
+  attachedCallback() {
+    this.style.backgroundColor = '#E0E0E0';
+    this.style.height = '100%';
+    this.style.flex = '1';
+  }
+}
+document.registerElement(MainContentElement.NAME, MainContentElement);
+
+/**
+ * A container and controller for the whole app.
+ * Contains the heading, side drawer and main panel.
+ */
+class AppElement extends HTMLElement {
+  static readonly NAME = 'heap-app';
+  private sidebar: SidebarElement;
+  private mainContent: MainContentElement;
+
+  attachedCallback() {
+    document.title = TITLE;
+
+    this.addEventListener(
+        ActionEvent.EVENT_TYPE, e => this.handleAction(e as ActionEvent),
+        /* capture */ true);
+
+    this.render();
+  }
+
+  render() {
+    this.style.display = 'block';
+    this.style.height = '100vh';
+    this.style.width = '100vw';
+    this.appendChild(document.createElement(HeadingElement.NAME));
+
+    const bodyDiv = document.createElement('div');
+    bodyDiv.style.height = '100%';
+    bodyDiv.style.display = 'flex';
+    this.sidebar =
+        document.createElement(SidebarElement.NAME) as SidebarElement;
+    bodyDiv.appendChild(this.sidebar);
+    this.mainContent =
+        document.createElement(MainContentElement.NAME) as MainContentElement;
+    bodyDiv.appendChild(this.mainContent);
+    this.appendChild(bodyDiv);
+
+    this.renderRoute();
+  }
+
+  renderRoute() {
+    this.mainContent.innerHTML = ''
+    switch (window.location.pathname) {
+      case '/about':
+        this.mainContent.appendChild(
+            document.createElement(AboutPageElement.NAME));
+        break;
+    }
+  }
+
+  handleAction(event: ActionEvent) {
+    switch (event.action) {
+      case Action.TOGGLE_SIDEBAR:
+        this.sidebar.toggle();
+        break;
+      case Action.NAVIGATE_ABOUT:
+        window.history.pushState({}, '', '/about');
+        this.renderRoute();
+        break;
+    }
+  }
+}
+document.registerElement(AppElement.NAME, AppElement);
+
+/**
+ * An about page.
+ */
+class AboutPageElement extends HTMLElement {
+  static readonly NAME = 'heap-about';
+
+  createdCallback() { this.textContent = TITLE; }
+}
+document.registerElement(AboutPageElement.NAME, AboutPageElement);
+
+/**
+ * Resets body's margin and padding, and sets font.
+ */
+function clearStyle(document: Document) {
+  const styleElement = document.createElement('style') as HTMLStyleElement;
+  document.head.appendChild(styleElement);
+  const styleSheet = styleElement.sheet as CSSStyleSheet;
+  styleSheet.insertRule(
+      '* {font-family: Roboto,Helvetica; box-sizing: border-box}', 0);
+  styleSheet.insertRule('body {margin: 0px; padding:0px}', 0);
 }
 
 export function main() {
-  document.title = 'Go Heap Viewer';
-  clearStyle();
-  document.body.appendChild(document.createElement("heap-heading"));
+  clearStyle(document);
+  document.body.appendChild(document.createElement(AppElement.NAME));
 }
diff --git a/cmd/heapview/client/main_test.ts b/cmd/heapview/client/main_test.ts
index 93da4e5..a208653 100644
--- a/cmd/heapview/client/main_test.ts
+++ b/cmd/heapview/client/main_test.ts
@@ -2,7 +2,7 @@
 // Use of this source code is governed by a BSD-style
 // license that can be found in the LICENSE file.
 
-import {main} from './main';
+import {HamburgerElement, HeadingElement, SidebarElement, main} from './main';
 
 describe('main', () => {
   it('sets the document\'s title', () => {
@@ -12,6 +12,18 @@
 
   it('has a heading', () => {
     main();
-    expect(document.querySelector('heap-heading')).toBeDefined();
+    expect(document.querySelector(HeadingElement.NAME)).toBeDefined();
   });
+
+  it('has a sidebar', () => {
+    main();
+    const hamburger = document.querySelector(HamburgerElement.NAME);
+    const sidebar =
+        document.querySelector(SidebarElement.NAME) as SidebarElement;
+    expect(sidebar.style.display).toBe('none');
+
+    // Click on the hamburger. Sidebar should then be visible.
+    hamburger.dispatchEvent(new Event('click'));
+    expect(sidebar.style.display).toBe('block');
+  })
 });
\ No newline at end of file
diff --git a/cmd/heapview/client/tsconfig.json b/cmd/heapview/client/tsconfig.json
index 5ce449b..14c38a1 100644
--- a/cmd/heapview/client/tsconfig.json
+++ b/cmd/heapview/client/tsconfig.json
@@ -10,6 +10,7 @@
 {
   "compilerOptions": {
     "noEmit": true,
-    "strictNullChecks": true
+    "strictNullChecks": true,
+    "target": "es2015"
   }
 }
diff --git a/cmd/heapview/main.go b/cmd/heapview/main.go
index 8d519c2..5fab790 100644
--- a/cmd/heapview/main.go
+++ b/cmd/heapview/main.go
@@ -15,6 +15,7 @@
 	"path/filepath"
 )
 
+var host = flag.String("host", "", "host addr to listen on")
 var port = flag.Int("port", 8080, "service port")
 
 var index = `<!DOCTYPE html>
@@ -68,12 +69,12 @@
 	})
 }
 
-var listenAndServe = func() {
-	log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil))
+var listenAndServe = func() error {
+	return http.ListenAndServe(fmt.Sprintf("%s:%d", *host, *port), nil)
 }
 
 func main() {
 	parseFlags()
 	addHandlers()
-	listenAndServe()
+	log.Fatal(listenAndServe())
 }