revise with layout, etc.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,2 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
|
||||
9
.swcrc
Normal file
9
.swcrc
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"jsc": {
|
||||
"parser": {
|
||||
"syntax": "typescript"
|
||||
},
|
||||
"target": "esnext"
|
||||
},
|
||||
"isModule": true
|
||||
}
|
||||
31
Makefile
31
Makefile
@@ -1,16 +1,31 @@
|
||||
build/MyCard.js: ./node_modules ./build
|
||||
npx swc ./src/MyCard.ts -o ./build/MyCard.js
|
||||
BUILD_DIR := ./dist
|
||||
SRC_DIR := ./src
|
||||
SRCS := $(shell find $(SRC_DIR) -name '*.ts')
|
||||
OBJS := $(SRCS:$(SRC_DIR)/%.ts=$(BUILD_DIR)/%.js) $(BUILD_DIR)/index.html
|
||||
HTML_SRCS := $(shell find $(SRC_DIR) -name '*.html')
|
||||
|
||||
./build:
|
||||
mkdir -p ./build
|
||||
all: $(OBJS)
|
||||
|
||||
$(BUILD_DIR):
|
||||
mkdir -p $(BUILD_DIR)
|
||||
|
||||
# Compile the ts files individually
|
||||
$(BUILD_DIR)/%.js: $(SRC_DIR)/%.ts ./node_modules
|
||||
mkdir -p $(dir $@)
|
||||
npx swc $< -o $@
|
||||
|
||||
# bundle all the templates into $(BUILD_DIR)/index.html
|
||||
$(BUILD_DIR)/index.html: export TEMPLATES = $(shell cat $(HTML_SRCS))
|
||||
$(BUILD_DIR)/index.html: $(HTML_SRCS) $(BUILD_DIR) ./index.template.html
|
||||
cat ./index.template.html | envsubst '$$TEMPLATES' | tr -d '\n' | sed -r 's/\s+/ /g' > $@
|
||||
|
||||
./node_modules:
|
||||
npm install
|
||||
|
||||
serve: ./build/MyCard.js
|
||||
npx serve
|
||||
serve: all
|
||||
npx serve -s $(BUILD_DIR)
|
||||
|
||||
clean:
|
||||
rm -rf ./build ./node_modules
|
||||
rm -rf $(BUILD_DIR) ./node_modules
|
||||
|
||||
.PHONY: clean serve
|
||||
.PHONY: all clean serve
|
||||
90
index.html
90
index.html
@@ -1,90 +0,0 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<title>MEDTRACE: Case - Frontend Developer</title>
|
||||
<script type="module" src="./build/MyCard.js"></script>
|
||||
<!-- "light" DOM styles -->
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
:root {
|
||||
/* init the CSS var from the "light" DOM */
|
||||
--my-card-header-color: red;
|
||||
}
|
||||
#cards {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 200px);
|
||||
grid-gap: 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- the template for the custom component -->
|
||||
<template id="my-card-template">
|
||||
<!-- shadow DOM styles -->
|
||||
<style>
|
||||
:host {
|
||||
box-shadow: 0px 0px 5px #ccc;
|
||||
display: block;
|
||||
padding: 10px;
|
||||
}
|
||||
header {
|
||||
/* using the CSS var assigned from the "light" DOM; default
|
||||
green is never displayed because the var is initialized
|
||||
above. */
|
||||
color: var(--my-card-header-color, green);
|
||||
}
|
||||
#message {
|
||||
margin: 5px;
|
||||
padding: 10px;
|
||||
background-color: #eee;
|
||||
}
|
||||
</style>
|
||||
|
||||
<header class="">
|
||||
<h1>
|
||||
<slot name="headline">
|
||||
Default headline
|
||||
</slot>
|
||||
</h1>
|
||||
</header>
|
||||
<!-- this could have been a slot named "main" like
|
||||
`<slot name="main"></slot>`
|
||||
but that makes it harder to use in the HTML below. -->
|
||||
<section id="main">
|
||||
<slot></slot>
|
||||
</section>
|
||||
<div id="message"></div>
|
||||
</template>
|
||||
|
||||
<!-- begin content -->
|
||||
<section id="cards">
|
||||
<my-card message="initial message parsed from HTML">
|
||||
<span slot="headline">Custom headline</span>
|
||||
<button id="my-button">click me</button>
|
||||
</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
</section>
|
||||
|
||||
<script>
|
||||
const cards = document.getElementsByTagName("my-card");
|
||||
const $myButton = document.getElementById("my-button");
|
||||
$myButton.addEventListener("click", () => {
|
||||
const msg = "button clicked";
|
||||
|
||||
for (const $card of cards) {
|
||||
$card.setAttribute(
|
||||
"message",
|
||||
$card.getAttribute("message") === msg
|
||||
? ""
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
81
index.template.html
Normal file
81
index.template.html
Normal file
@@ -0,0 +1,81 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta content="width=device-width, initial-scale=1" name="viewport"/>
|
||||
|
||||
<title>MEDTRACE: Case - Frontend Developer</title>
|
||||
<!-- <script type="module" src="./MyCard/MyCard.js"></script> -->
|
||||
<script type="module" src="./App/App.js"></script>
|
||||
<!-- "light" DOM styles -->
|
||||
<style>
|
||||
html, body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
:root {
|
||||
/* init the CSS var from the "light" DOM */
|
||||
/* --theme-primary: #b2ff0b;
|
||||
--theme-primary-pale: #90aa00;
|
||||
--theme-secondary: #411dc9;
|
||||
--theme-secondary-hover: #512dd9;
|
||||
--theme-secondary-dark: #020c67;
|
||||
--theme-secondary-dark-hover: #121c7f; */
|
||||
--theme-error: #ff5a58;
|
||||
--theme-error-hover: #ff7a78;
|
||||
/* --theme-background: #1a1a19;
|
||||
--theme-title-color: #fff;
|
||||
--theme-ink-color: #ebeae2;
|
||||
--theme-font-family: "Roboto", sans-serif; */
|
||||
|
||||
--theme-primary: #caa026;
|
||||
--theme-primary-subdued: #846918;
|
||||
--theme-primary-pale: #e8cf87;
|
||||
--theme-secondary: #251f65;
|
||||
--theme-secondary-hover: #332a8b;
|
||||
--theme-secondary-dark: #1d1850;
|
||||
/* --theme-secondary-dark-hover: #121c7f; */
|
||||
|
||||
--theme-background: #120f32;
|
||||
--theme-background-top-bar: #120f32fa;
|
||||
--theme-background-dark: #0b0920;
|
||||
--theme-title-color: #fff;
|
||||
--theme-ink-color: #fff;
|
||||
--theme-font-family: "Roboto", sans-serif;
|
||||
|
||||
--primary-button-background-color: var(--theme-secondary);
|
||||
--primary-button-background-hover-color: var(--theme-primary);
|
||||
--secondary-button-background-color: #555;
|
||||
--secondary-button-background-color: black; /* rgb(5, 12, 38); */
|
||||
--secondary-button-background-hover-color: #656565;
|
||||
--secondary-button-color: white;
|
||||
--secondary-button-border: 1px solid var(--dialog-content-color);
|
||||
--disabled-button-background-color: #676767;
|
||||
--disabled-button-color: #b5b5b5;
|
||||
--base-transition: all 200ms ease-in-out;
|
||||
|
||||
--my-card-header-color: var(--theme-primary);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
|
||||
background-color: var(--theme-background);
|
||||
background-image: url("/assets/background.png");
|
||||
background-size: cover;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: scroll;
|
||||
}
|
||||
</style>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
|
||||
rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
${TEMPLATES}
|
||||
|
||||
<pj-app default-route="/cards/"></pj-app>
|
||||
</body>
|
||||
</html>
|
||||
2
package-lock.json
generated
2
package-lock.json
generated
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "medtrace",
|
||||
"name": "web",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
|
||||
178
src/App/App.html
Normal file
178
src/App/App.html
Normal file
@@ -0,0 +1,178 @@
|
||||
<template id="app-template">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>
|
||||
:host {
|
||||
color: var(--main-text-color);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
#cards {
|
||||
padding: 20px;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 150px);
|
||||
grid-gap: 2rem 1rem;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
a {
|
||||
background-color: var(--theme-secondary-dark);
|
||||
text-decoration: none;
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
a:hover {
|
||||
background-color: var(--theme-secondary-dark-hover);
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.context-menu {
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
}
|
||||
.context-menu-item {
|
||||
/* nothing yet */
|
||||
}
|
||||
.context-menu-item > a {
|
||||
display: block;
|
||||
font-size: 25pt;
|
||||
padding: 5px;
|
||||
padding-left: 6px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 4px #55a;
|
||||
color: var(--theme-ink-color);
|
||||
}
|
||||
.context-menu-item > a:hover {
|
||||
box-shadow: 0 0 10px #55a;
|
||||
}
|
||||
|
||||
#main-menu {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
#not-found {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
#main {
|
||||
margin-bottom: 100px;
|
||||
margin-top: 5px;
|
||||
}
|
||||
#breadcrumbs {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0 var(--main-side-margin) 0 5px;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
#breadcrumbs li {
|
||||
margin: 0 6px 0 0;
|
||||
padding: 10px 0;
|
||||
margin: 0;
|
||||
display: none;
|
||||
}
|
||||
#breadcrumbs li.show {
|
||||
display: inline-block;
|
||||
}
|
||||
#breadcrumbs li i {
|
||||
margin: 0 1px;
|
||||
vertical-align: bottom;
|
||||
}
|
||||
#breadcrumbs li a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
[slot] paper-button {
|
||||
margin: 0;
|
||||
text-decoration: none;
|
||||
color: var(--main-text-color);
|
||||
padding: 0 5px 0 0;
|
||||
min-width: 0px;
|
||||
border-radius: 50%;
|
||||
background: #ffffff55;
|
||||
height: 32px;
|
||||
border: 1px solid white;
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
box-shadow: 0 0 3px #ddd;
|
||||
box-shadow: none;
|
||||
}
|
||||
[slot] paper-button i {
|
||||
font-size: var(--icon-size);
|
||||
}
|
||||
.context-menu-spacer {
|
||||
border-right: 1px dotted #fff;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
#hamburger {
|
||||
/* display: none; */
|
||||
}
|
||||
#hamburger.show {
|
||||
display: initial;
|
||||
}
|
||||
|
||||
|
||||
#breadcrumbs li.show {
|
||||
padding: 0;
|
||||
/* margin-top: -20px; */
|
||||
font-weight: lighter;
|
||||
}
|
||||
#breadcrumbs li.show:last-child a {
|
||||
/* font-size: 1.5em; */
|
||||
font-weight: bold;
|
||||
}
|
||||
#breadcrumbs li.show a {
|
||||
font-size: .9em;
|
||||
}
|
||||
|
||||
@media screen and (min-width: 700px) {
|
||||
#breadcrumbs {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<pj-layout id="layout">
|
||||
<a slot="main-title" href="/"><div id="logo"></div></a>
|
||||
|
||||
<div class="context-menu" slot="context-menu">
|
||||
<div class="context-menu-item">
|
||||
<a id="main-menu" class="material-icons" tabindex="0"></a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="main" slot="main">
|
||||
<pj-pages id="pages" attr-for-selected="name" default-selection="not-found">
|
||||
<div name="cards">
|
||||
<section id="cards">
|
||||
<my-card>
|
||||
<span slot="headline">Custom headline</span>
|
||||
<button id="my-button">click me</button>
|
||||
</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
<my-card>hello world</my-card>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<h1 name="not-found" id="not-found">Not found</h1>
|
||||
<h1 name="bad-route">Bad route</h1>
|
||||
<h1 name="not-implemented">Not implemented yet</h1>
|
||||
<h1 name="server-error">Server error</h1>
|
||||
</pj-pages>
|
||||
</div>
|
||||
|
||||
<pj-nav id="nav" slot="right-drawer" display-mode="stack"></pj-nav>
|
||||
</pj-layout>
|
||||
</template>
|
||||
192
src/App/App.ts
Normal file
192
src/App/App.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import "/Pages/Pages.js";
|
||||
import Pages from "../Pages/Pages.js";
|
||||
|
||||
import "../Layout/Layout.js";
|
||||
import Layout from "../Layout/Layout.js";
|
||||
|
||||
import "../Nav/Nav.js";
|
||||
import Nav, { MenuItem } from "../Nav/Nav.js";
|
||||
|
||||
import Route from "../Route/Route.js";
|
||||
import * as utils from "../utils.js";
|
||||
|
||||
import "../MyCard/MyCard.js";
|
||||
|
||||
// import materialIcons from "../../material-icons-link.html";
|
||||
// import commonCSS from "../styles/common.css";
|
||||
// import logo from "../../assets/logo.svg";
|
||||
|
||||
const template = document.getElementById("app-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
const navigationItems: MenuItem[] = [
|
||||
{
|
||||
name: "cards",
|
||||
label: "Cards",
|
||||
href: "/cards/",
|
||||
icon: "library_books",
|
||||
spa: true,
|
||||
},
|
||||
{
|
||||
name: "patients",
|
||||
label: "Patients",
|
||||
href: "/patients/",
|
||||
icon: "face",
|
||||
spa: true,
|
||||
},
|
||||
];
|
||||
|
||||
enum Event {
|
||||
BAD_ROUTE = "pj:bad-route",
|
||||
}
|
||||
|
||||
window.customElements.define("pj-app", class extends HTMLElement {
|
||||
private _content: DocumentFragment;
|
||||
$nav: Nav;
|
||||
$layout: Layout;
|
||||
$mainMenuButton: HTMLAnchorElement;
|
||||
$pages: Pages;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode: "open"});
|
||||
this._content = template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
|
||||
this.$layout =
|
||||
this._content.getElementById("layout") as
|
||||
Layout;
|
||||
this.$nav =
|
||||
this._content.getElementById("nav") as
|
||||
Nav;
|
||||
this.$mainMenuButton =
|
||||
this._content.getElementById("main-menu") as
|
||||
HTMLAnchorElement;
|
||||
this.$pages =
|
||||
this._content.getElementById("pages") as
|
||||
Pages;
|
||||
|
||||
// (this._content.getElementById("logo") as HTMLElement).innerHTML = logo;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
|
||||
this.bind();
|
||||
|
||||
this.route(window.location.pathname);
|
||||
}
|
||||
|
||||
bind() {
|
||||
this.addEventListener(Event.BAD_ROUTE, e => {
|
||||
this.$pages.select((e as CustomEvent).detail);
|
||||
});
|
||||
|
||||
this.$mainMenuButton.addEventListener("click", e => {
|
||||
this.$layout.openDrawer("right");
|
||||
});
|
||||
|
||||
this.initNavigation(navigationItems);
|
||||
this.initRouting();
|
||||
this.initMedTraceRequirement();
|
||||
}
|
||||
|
||||
initMedTraceRequirement() {
|
||||
const cards = this.shadowRoot!.querySelectorAll("my-card");
|
||||
const $myButton = this.shadowRoot!.getElementById("my-button");
|
||||
|
||||
$myButton?.addEventListener("click", () => {
|
||||
const msg = "button clicked";
|
||||
|
||||
for (const $card of cards) {
|
||||
$card.setAttribute(
|
||||
"message",
|
||||
$card.getAttribute("message") === msg
|
||||
? ""
|
||||
: msg,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
initRouting() {
|
||||
window.addEventListener("popstate", e => {
|
||||
// console.log("Dapp/top-level: popstate", window.location.href);
|
||||
this.route(window.location.pathname);
|
||||
this.$layout.closeDrawer("right");
|
||||
});
|
||||
}
|
||||
|
||||
navRelocate(
|
||||
screenTallEnoughForFooterNav: boolean,
|
||||
screenWideEnoughForDrawer: boolean,
|
||||
) {
|
||||
if (screenWideEnoughForDrawer) {
|
||||
this.$nav.slot = "right-drawer";
|
||||
this.$nav.setAttribute("display-mode", "stack");
|
||||
} else if (screenTallEnoughForFooterNav) {
|
||||
this.$nav.slot = "footer";
|
||||
this.$nav.setAttribute("display-mode", "flex");
|
||||
} else {
|
||||
this.$nav.slot = "right-drawer";
|
||||
this.$nav.setAttribute("display-mode", "stack");
|
||||
}
|
||||
|
||||
if (this.$nav.slot === "right-drawer") {
|
||||
this.$mainMenuButton.classList.remove("hidden");
|
||||
} else {
|
||||
this.$mainMenuButton.classList.add("hidden");
|
||||
this.$layout.closeDrawer("right");
|
||||
}
|
||||
}
|
||||
|
||||
initNavigation(navigationItems: MenuItem[]) {
|
||||
navigationItems.forEach(item => this.$nav.addItem(item));
|
||||
this.$nav.init();
|
||||
|
||||
this.$layout.breakpointer.addHandler(this.navRelocate.bind(this));
|
||||
}
|
||||
|
||||
async route(path?: string) {
|
||||
if (!path) {
|
||||
return this.$pages.select("not-found");
|
||||
}
|
||||
|
||||
const route = new Route(`/:page`, path);
|
||||
const request = {
|
||||
// imbuer: this.imbuer,
|
||||
// accounts: this.accounts,
|
||||
// apiInfo: this.apiInfo,
|
||||
}
|
||||
|
||||
console.log(path, route);
|
||||
|
||||
if (!route.active) {
|
||||
/**
|
||||
* the path == `/app`, so we redirect to the default "app", which
|
||||
* is currently "/app/cards"
|
||||
*/
|
||||
utils.redirect(
|
||||
this.getAttribute("default-route") || "/cards/"
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (route.data?.page) {
|
||||
this.$nav.selected = route.data.page;
|
||||
}
|
||||
|
||||
switch (route.data?.page) {
|
||||
case "cards":
|
||||
this.$pages.select("cards");
|
||||
// (this.$pages.selected as Cards).route(route.tail, request);
|
||||
break;
|
||||
case "patients":
|
||||
this.$pages.select("patients");
|
||||
// (this.$pages.selected as Patients).route(route.tail, request);
|
||||
break;
|
||||
default:
|
||||
this.$pages.select("not-found");
|
||||
}
|
||||
}
|
||||
});
|
||||
42
src/ECGTicker/ECGTicker.ts
Normal file
42
src/ECGTicker/ECGTicker.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
const template = document.getElementById("my-card-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
enum Attributes {
|
||||
MESSAGE = "message",
|
||||
}
|
||||
|
||||
export default class ECGTicker extends HTMLElement {
|
||||
static observedAttributes = [Attributes.MESSAGE];
|
||||
|
||||
private _content: DocumentFragment;
|
||||
$message: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
|
||||
this.attachShadow({mode: "open"});
|
||||
this._content = template.content.cloneNode(true) as DocumentFragment;
|
||||
this.$message = this._content.getElementById("message")!;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this.shadowRoot) {
|
||||
this.shadowRoot.appendChild(this._content);
|
||||
} else {
|
||||
console.warn("No shadowRoot detected.");
|
||||
}
|
||||
}
|
||||
|
||||
attributeChangedCallback(name: string, _prev: string, curr: string) {
|
||||
if (Attributes.MESSAGE === name) {
|
||||
while (this.$message.firstChild) {
|
||||
this.$message.removeChild(this.$message.lastChild!);
|
||||
}
|
||||
this.$message.appendChild(document.createTextNode(curr));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.customElements.define("x-ecg-ticker", ECGTicker);
|
||||
|
||||
export {};
|
||||
242
src/Layout/Layout.html
Normal file
242
src/Layout/Layout.html
Normal file
@@ -0,0 +1,242 @@
|
||||
<template id="layout-template">
|
||||
<style>
|
||||
:host {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-content: baseline;
|
||||
height: 100%;
|
||||
color: var(--theme-ink-color);
|
||||
flex-wrap: wrap;
|
||||
--pj-layout-header-height: 2.5rem;
|
||||
margin: auto;
|
||||
min-height: 100vh;
|
||||
min-height: calc(100vh - 55px);
|
||||
--pj-layout-padding: 30px;
|
||||
padding: 0;
|
||||
width: 100%;
|
||||
--pj-layout-max-screen: 1366px;
|
||||
}
|
||||
|
||||
.padded {
|
||||
transition: padding 200ms ease-in-out;
|
||||
padding: var(--pj-layout-padding);
|
||||
}
|
||||
#header-wrapper {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
top: 0;
|
||||
background: var(--theme-background-dark);
|
||||
transition:
|
||||
background 200ms ease-in-out,
|
||||
box-shadow 200ms ease-in-out,
|
||||
padding 200ms ease-in-out;
|
||||
}
|
||||
#header-wrapper.hidden {
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
transform: translateY(-125px);
|
||||
}
|
||||
#header-wrapper.hidden-transition {
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
#header-wrapper.sticky {
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
z-index: 1;
|
||||
position: sticky;
|
||||
background: var(--theme-background-top-bar);
|
||||
transform: translateY(0px);
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
#footer {
|
||||
width: 100%;
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
#footer.hidden {
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
transform: translateY(75px);
|
||||
}
|
||||
#footer.hidden-transition {
|
||||
transition: all 150ms ease-in-out;
|
||||
}
|
||||
#footer.sticky {
|
||||
box-shadow: 0 0 5px #aaa;
|
||||
transform: translateY(0px);
|
||||
transition: transform 150ms ease-in-out;
|
||||
}
|
||||
#main-header {
|
||||
display: flex;
|
||||
z-index: 1;
|
||||
flex-wrap: wrap;
|
||||
line-height: 1.15rem;
|
||||
}
|
||||
#main-header h1 {
|
||||
font-size: 1.15rem;
|
||||
margin: 0;
|
||||
}
|
||||
.spacer {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
}
|
||||
.main-title {
|
||||
display: block;
|
||||
margin: 0 0 0 .5rem;
|
||||
margin: 0;
|
||||
width: 160px;
|
||||
height: 43px;
|
||||
}
|
||||
#main-content {
|
||||
overflow: auto;
|
||||
width: 100vw;
|
||||
min-height: inherit;
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
width: 80vw;
|
||||
overflow: auto;
|
||||
transition: transform 200ms cubic-bezier(1,0,0,1);
|
||||
transition: all 200ms ease-in-out;
|
||||
}
|
||||
#left-drawer.open, #right-drawer.open {
|
||||
transform: translateX(0);
|
||||
z-index: 2;
|
||||
}
|
||||
#left-drawer {
|
||||
left: 0;
|
||||
background: rgba(90,90,90,.90);
|
||||
transform: translateX(-100%);
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
z-index: 1;
|
||||
}
|
||||
#right-drawer {
|
||||
right: 0;
|
||||
background: var(--theme-background-dark);
|
||||
transform: translateX(100%);
|
||||
z-index: 1;
|
||||
}
|
||||
.modal {
|
||||
width: 100vw;
|
||||
max-width: 100%;
|
||||
transform: translateX(-100vw);
|
||||
position: fixed;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: rgba(200,200,200,.10);
|
||||
opacity: 0;
|
||||
}
|
||||
.modal.show {
|
||||
transform: translateX(0);
|
||||
opacity: 1;
|
||||
}
|
||||
#loading-modal {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
transition: opacity 1000ms ease-in;
|
||||
}
|
||||
#loading-modal svg {
|
||||
max-width: 10em;
|
||||
}
|
||||
#context-menu {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
width: 55%;
|
||||
max-width: 200px;
|
||||
height: 35px;
|
||||
}
|
||||
#context-menu.hidden {
|
||||
display: none;
|
||||
}
|
||||
.context-menu-spacer {
|
||||
border-right: 1px dotted #ccc;
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
@media screen
|
||||
/* and (min-width: 500px) */
|
||||
and (max-width: 500px) {
|
||||
.padded {
|
||||
padding: calc(var(--pj-layout-padding) / 2);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Left hand side menu is now fixed in place and hamburger goes away.
|
||||
*/
|
||||
@media screen
|
||||
and (min-width: 500px) {
|
||||
|
||||
:host {
|
||||
/* min-width: 700px; */
|
||||
max-width: var(--pj-layout-max-screen);
|
||||
}
|
||||
#main-content {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
slot[name="main"] {
|
||||
width: 1%;
|
||||
display: block;
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 1366px) {
|
||||
#header-wrapper.sticky {
|
||||
box-shadow: none;
|
||||
z-index: auto;
|
||||
position: initial;
|
||||
background: inherit;
|
||||
transform: translateY(0px);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media screen and (min-width: 700px) {
|
||||
.drawer {
|
||||
width: 30vw;
|
||||
min-width: 300px;
|
||||
max-width: 555px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<header id="header-wrapper" class="padded">
|
||||
<div id="main-header">
|
||||
<h1 class="main-title">
|
||||
<slot name="main-title"></slot>
|
||||
</h1>
|
||||
<div class="spacer"></div>
|
||||
<div id="context-menu">
|
||||
<slot name="context-menu"></slot>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="modal" id="modal"></div>
|
||||
<div class="modal" id="loading-modal">
|
||||
<!-- SVG spinner -->
|
||||
</div>
|
||||
|
||||
<div class="drawer" id="left-drawer">
|
||||
<slot name="left-drawer"></slot>
|
||||
</div>
|
||||
<div class="drawer" id="right-drawer">
|
||||
<slot name="right-drawer"></slot>
|
||||
</div>
|
||||
|
||||
<main id="main-content" class="padded">
|
||||
<slot name="main"></slot>
|
||||
</main>
|
||||
|
||||
<div id="footer" class="padded">
|
||||
<slot name="footer"></slot>
|
||||
</div>
|
||||
</template>
|
||||
222
src/Layout/Layout.ts
Normal file
222
src/Layout/Layout.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
import { Mutex, debounce } from "../utils.js";
|
||||
|
||||
// import materialIcons from "../html/material-icons-link.html";
|
||||
// import globe from "../../assets/world.svg";
|
||||
|
||||
export type Breakpoints = {
|
||||
minHeight: number;
|
||||
minWidth: number;
|
||||
}
|
||||
|
||||
class Breakpointer {
|
||||
heightMatcher: MediaQueryList;
|
||||
widthMatcher: MediaQueryList;
|
||||
|
||||
constructor(breakpoints: Breakpoints) {
|
||||
this.heightMatcher = matchMedia(`(min-height: ${breakpoints.minHeight}px)`);
|
||||
this.widthMatcher = matchMedia(`(min-width: ${breakpoints.minWidth}px)`);
|
||||
}
|
||||
|
||||
addHandler(handler: (h: boolean, w: boolean) => void): void {
|
||||
const job = () => handler(
|
||||
this.heightMatcher.matches,
|
||||
this.widthMatcher.matches,
|
||||
);
|
||||
|
||||
this.heightMatcher.addEventListener("change", job);
|
||||
this.widthMatcher.addEventListener("change", job);
|
||||
job();
|
||||
}
|
||||
}
|
||||
|
||||
const template = document.getElementById("layout-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
const DIVIDER = Symbol();
|
||||
export {
|
||||
DIVIDER
|
||||
}
|
||||
|
||||
type DrawerSide = "left"|"right";
|
||||
type ClasslistMethod = "toggle"|"add"|"remove";
|
||||
|
||||
|
||||
export default class Layout extends HTMLElement {
|
||||
|
||||
private _content: DocumentFragment;
|
||||
|
||||
$mainTitle: HTMLElement;
|
||||
$contextMenu: HTMLElement;
|
||||
$loadingModal: HTMLElement;
|
||||
$modal: HTMLElement;
|
||||
|
||||
$header: HTMLElement;
|
||||
$footer: HTMLElement;
|
||||
|
||||
$drawers: {
|
||||
left: HTMLElement;
|
||||
right: HTMLElement;
|
||||
}
|
||||
breakpointer: Breakpointer;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode:"open"});
|
||||
this._content =
|
||||
template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
|
||||
this.$mainTitle =
|
||||
this._content.getElementById("main-title") as
|
||||
HTMLElement;
|
||||
|
||||
this.$contextMenu =
|
||||
this._content.getElementById("context-menu") as
|
||||
HTMLElement;
|
||||
|
||||
this.$loadingModal =
|
||||
this._content.getElementById("loading-modal") as
|
||||
HTMLElement;
|
||||
|
||||
this.$header =
|
||||
this._content.getElementById("header-wrapper") as
|
||||
HTMLElement;
|
||||
this.$footer =
|
||||
this._content.getElementById("footer") as
|
||||
HTMLElement;
|
||||
|
||||
this.$drawers = {
|
||||
left: this._content.getElementById("left-drawer") as
|
||||
HTMLElement,
|
||||
right: this._content.getElementById("right-drawer") as
|
||||
HTMLElement,
|
||||
};
|
||||
|
||||
this.$modal =
|
||||
this._content.getElementById("modal") as
|
||||
HTMLElement;
|
||||
|
||||
const drawerWidth = 500;
|
||||
const bottomNavHeight = 600;
|
||||
this.breakpointer = new Breakpointer({
|
||||
minHeight: bottomNavHeight,
|
||||
minWidth: drawerWidth,
|
||||
});
|
||||
|
||||
this.bindDrawerListeners();
|
||||
this.bindAnimatedHeader();
|
||||
this.bindLoading();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
this.breakpointer.addHandler(this.handleBreakpoints.bind(this));
|
||||
}
|
||||
|
||||
handleBreakpoints(minHeight: boolean, minWidth: boolean) {
|
||||
this.$footer.classList[
|
||||
minWidth ? "add" : "remove"
|
||||
]("padded");
|
||||
|
||||
this.$contextMenu.classList[
|
||||
minWidth ? "remove" : "add"
|
||||
]("hidden");
|
||||
}
|
||||
|
||||
bindLoading() {
|
||||
let loadingCount = 0;
|
||||
const mutex = new Mutex;
|
||||
|
||||
this.addEventListener("loading-start", (async (e) => {
|
||||
const unlock = await mutex.lock();
|
||||
loadingCount++;
|
||||
this.$loadingModal.classList.add("show");
|
||||
unlock();
|
||||
}));
|
||||
|
||||
this.addEventListener("loading-end", (async (e) => {
|
||||
const unlock = await mutex.lock();
|
||||
if (--loadingCount <= 0) {
|
||||
this.$loadingModal.classList.remove("show");
|
||||
loadingCount = 0;
|
||||
}
|
||||
unlock();
|
||||
}));
|
||||
}
|
||||
|
||||
bindAnimatedHeader() {
|
||||
let lastWinTop = 0;
|
||||
window.addEventListener("scroll", debounce(() => {
|
||||
const headerHeight = this.$header.offsetHeight;
|
||||
const classList = this.$header.classList;
|
||||
const winTop = window.scrollY;
|
||||
if (winTop < 1) {
|
||||
classList.remove("sticky");
|
||||
classList.remove("hidden");
|
||||
classList.remove("hidden-transition");
|
||||
} else if (winTop < lastWinTop) {
|
||||
classList.add("sticky");
|
||||
classList.remove("hidden");
|
||||
if (lastWinTop > headerHeight)
|
||||
classList.add("hidden-transition");
|
||||
} else if (winTop > headerHeight && winTop > lastWinTop) {
|
||||
classList.remove("sticky");
|
||||
classList.add("hidden");
|
||||
}
|
||||
|
||||
this.$footer.className = "";
|
||||
classList.forEach(x => {
|
||||
if (x !== "padded")
|
||||
this.$footer.classList.add(x)
|
||||
});
|
||||
|
||||
lastWinTop = winTop;
|
||||
}, 10));
|
||||
}
|
||||
|
||||
bindDrawerListeners() {
|
||||
this.$drawers.left.addEventListener("click", e => this.handleCloseClick(e, "left"));
|
||||
this.$modal.addEventListener("click", e => this.closeDrawers());
|
||||
window.addEventListener("keyup", e => {
|
||||
if (e.key === "Escape")
|
||||
this.closeDrawers();
|
||||
});
|
||||
this.addEventListener("layout-drawer-toggle", (e: Event) => {
|
||||
const {which, action} = (e as CustomEvent).detail;
|
||||
this.toggleDrawer(which, action);
|
||||
});
|
||||
}
|
||||
|
||||
closeDrawers() {
|
||||
this.closeDrawer("left");
|
||||
this.closeDrawer("right");
|
||||
}
|
||||
toggleDrawer(which: DrawerSide, action: ClasslistMethod = "toggle") {
|
||||
this.$drawers[which].classList[action]("open");
|
||||
this.$modal.classList[action]("show");
|
||||
document.documentElement.classList[action]("modal");
|
||||
}
|
||||
closeDrawer(which: DrawerSide) { this.toggleDrawer(which, "remove"); }
|
||||
openDrawer(which: DrawerSide) { this.toggleDrawer(which, "add"); }
|
||||
|
||||
handleCloseClick(e: Event, which: DrawerSide = "left", force?: boolean) {
|
||||
const $target = e.target as Element;
|
||||
if (!force && $target.slot === `${which}-drawer`)
|
||||
return;
|
||||
this.closeDrawer(which);
|
||||
}
|
||||
|
||||
set title(src: string) {
|
||||
this.$mainTitle.innerHTML = src;
|
||||
}
|
||||
|
||||
/**
|
||||
* `src` here is HTML that might be an SVG or an `img` tag with `src`
|
||||
* attribute set appropriately, etc.
|
||||
*/
|
||||
set loadingModalImg(src: string) {
|
||||
this.$loadingModal.innerHTML = src;
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define("pj-layout", Layout);
|
||||
46
src/MyCard/MyCard.html
Normal file
46
src/MyCard/MyCard.html
Normal file
@@ -0,0 +1,46 @@
|
||||
<!-- the template for the custom component -->
|
||||
<template id="my-card-template">
|
||||
<!-- shadow DOM styles -->
|
||||
<style>
|
||||
:host {
|
||||
box-shadow: 0px 0px 5px var(--theme-primary-pale);
|
||||
display: block;
|
||||
padding: 0 20px 10px 20px;
|
||||
transition: var(--base-transition);
|
||||
font-family: sans-serif;
|
||||
min-height: 222px;
|
||||
/* border-radius: 3px; */
|
||||
}
|
||||
:host(:hover) {
|
||||
box-shadow: 0px 0px 20px var(--theme-primary);
|
||||
}
|
||||
header {
|
||||
/* using the CSS var assigned from the "light" DOM; default
|
||||
green is never displayed because the var is initialized
|
||||
above. */
|
||||
color: var(--my-card-header-color, green);
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-size: 1.75rem;
|
||||
}
|
||||
#message {
|
||||
margin: 15px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<header class="">
|
||||
<h1>
|
||||
<slot name="headline">
|
||||
Default headline
|
||||
</slot>
|
||||
</h1>
|
||||
</header>
|
||||
<!-- this could have been a slot named "main" like
|
||||
`<slot name="main"></slot>`
|
||||
but that makes it harder to use in the HTML below. -->
|
||||
<section id="main">
|
||||
<slot></slot>
|
||||
</section>
|
||||
<div id="message"></div>
|
||||
</template>
|
||||
@@ -1,3 +1,7 @@
|
||||
import ECGTicker from "../ECGTicker/ECGTicker.js";
|
||||
|
||||
console.log(ECGTicker);
|
||||
|
||||
/**
|
||||
* This could also be imported as a string or written here and attached to the
|
||||
* `document.body` as a `template` element, like
|
||||
@@ -46,4 +50,6 @@ window.customElements.define("my-card", class extends HTMLElement {
|
||||
this.$message.appendChild(document.createTextNode(curr));
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
126
src/Nav/Nav.html
Normal file
126
src/Nav/Nav.html
Normal file
@@ -0,0 +1,126 @@
|
||||
<template id="nav-template">
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css?family=Material+Icons&display=block"
|
||||
rel="stylesheet">
|
||||
|
||||
<style>#nav.hidden {display: none;}</style>
|
||||
|
||||
<style id="stack">
|
||||
:host {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
}
|
||||
nav {
|
||||
color: var(--theme-ink-color);
|
||||
}
|
||||
ul {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 28px 0;
|
||||
font-size: 1.25rem;
|
||||
font-family: var(--theme-font-family);
|
||||
}
|
||||
nav > ul > li {
|
||||
transition: background 200ms ease-in-out;
|
||||
background: var(--theme-background-dark);
|
||||
flex-grow: 1;
|
||||
}
|
||||
nav > ul > li:hover {
|
||||
background: var(--theme-secondary-dark);
|
||||
transition: background 200ms ease-in-out;
|
||||
}
|
||||
nav > ul .selected, nav > ul .selected:hover {
|
||||
font-weight: bold;
|
||||
background-color: var(--theme-secondary-dark-hover); /* ??? */
|
||||
background: var(--theme-primary);
|
||||
}
|
||||
nav > ul .selected i ~ span::after {
|
||||
/*content: "*";*/
|
||||
}
|
||||
a {
|
||||
color: white;
|
||||
display: flex;
|
||||
text-decoration: none;
|
||||
padding: 12px 20px;
|
||||
}
|
||||
i.material-icons {
|
||||
font-size: var(--icon-size);
|
||||
padding-top: 2px;
|
||||
}
|
||||
i ~ span {
|
||||
margin-left: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style id="flex">
|
||||
:host {
|
||||
flex-grow: 1;
|
||||
display: block;
|
||||
}
|
||||
ul {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
box-shadow: 0 0 10px #777;
|
||||
}
|
||||
ul li {
|
||||
width: 25%;
|
||||
}
|
||||
li > a {
|
||||
color: var(--main-text-color);
|
||||
display: block;
|
||||
padding: 10px;
|
||||
}
|
||||
i.material-icons {
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 1.5rem;
|
||||
font-size: 20pt;
|
||||
padding: 4px;
|
||||
}
|
||||
i ~ span {
|
||||
font-size: .8rem;
|
||||
text-align: center;
|
||||
display: block;
|
||||
margin: 0;
|
||||
}
|
||||
nav > ul {
|
||||
padding: 0;
|
||||
}
|
||||
nav > ul .selected i ~ span::after {
|
||||
content: none;
|
||||
}
|
||||
nav > ul .selected {
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style id="tab">
|
||||
nav > ul {
|
||||
background: #eee;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
nav > ul > li {
|
||||
max-width: 100px;
|
||||
flex-grow: 0;
|
||||
margin: 5px 10px 0 0;
|
||||
margin: 10px 10px 0 0;
|
||||
}
|
||||
ul > li a {
|
||||
background: #ccc;
|
||||
background: #eee;
|
||||
border-radius: 10% 10% 0 0;
|
||||
padding: 5px 0;
|
||||
}
|
||||
nav > ul .selected {
|
||||
box-shadow: 0 -2px 3px #aaa;
|
||||
}
|
||||
nav > ul .selected {
|
||||
font-weight: bold;
|
||||
background: inherit;
|
||||
}
|
||||
</style>
|
||||
|
||||
<nav id="nav" class="hidden">
|
||||
<ul id="menu"></ul>
|
||||
</nav>
|
||||
</template>
|
||||
188
src/Nav/Nav.ts
Normal file
188
src/Nav/Nav.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
const template = document.getElementById("nav-template") as
|
||||
HTMLTemplateElement;
|
||||
|
||||
const navItemTemplate = document.createElement("template");
|
||||
navItemTemplate.innerHTML = `
|
||||
<li class="menu-item">
|
||||
<a>
|
||||
<i class="material-icons" aria-hidden="true"></i>
|
||||
<span></span>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
const displayModes = ["stack","flex","tab"] as const;
|
||||
type DisplayMode = typeof displayModes[number];
|
||||
export type MenuItem = {
|
||||
name: string;
|
||||
label: string;
|
||||
icon: string;
|
||||
href: string;
|
||||
selected?: boolean;
|
||||
spa?: boolean;
|
||||
}
|
||||
|
||||
export default class Nav extends HTMLElement {
|
||||
|
||||
static observedAttributes = ["display-mode"];
|
||||
|
||||
_content: DocumentFragment;
|
||||
_selected?: MenuItem;
|
||||
_items: MenuItem[] = [];
|
||||
_styles: Record<DisplayMode,HTMLStyleElement> = {} as any;
|
||||
$nav: HTMLElement;
|
||||
$menu: HTMLUListElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode:"open"});
|
||||
this._content =
|
||||
template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
|
||||
this.$nav =
|
||||
this._content.getElementById("nav") as
|
||||
HTMLElement;
|
||||
this.$menu =
|
||||
this._content.getElementById("menu") as
|
||||
HTMLUListElement;
|
||||
|
||||
displayModes.forEach(mode => {
|
||||
this._styles[mode] =
|
||||
this._content.getElementById(mode) as
|
||||
HTMLStyleElement;
|
||||
});
|
||||
|
||||
this._clearStyles();
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
if (this._content.hasChildNodes()) {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
this.displayMode = "none";
|
||||
}
|
||||
}
|
||||
|
||||
private _clearStyles() {
|
||||
displayModes.forEach(x => {
|
||||
const $style = this._styles[x];
|
||||
// parentNode is shadowRoot or null
|
||||
$style.parentNode?.removeChild($style);
|
||||
});
|
||||
}
|
||||
|
||||
set displayMode(x: DisplayMode | "none") {
|
||||
if (!this.shadowRoot?.hasChildNodes()) {
|
||||
return;
|
||||
}
|
||||
const styles = document.createDocumentFragment();
|
||||
|
||||
this.$nav.classList.add("hidden");
|
||||
this._clearStyles();
|
||||
|
||||
switch(x) {
|
||||
case "none":
|
||||
return;
|
||||
case "tab":
|
||||
styles.appendChild(this._styles.tab);
|
||||
// no break
|
||||
case "flex":
|
||||
styles.prepend(this._styles.flex);
|
||||
// no break
|
||||
default:
|
||||
this.$nav.classList.remove("hidden");
|
||||
styles.prepend(this._styles.stack);
|
||||
this.shadowRoot?.insertBefore(styles, this.$nav);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
get selectedItem(): MenuItem | undefined {
|
||||
return this._selected;
|
||||
}
|
||||
set selected(name: string) {
|
||||
this.items.forEach(item => {
|
||||
if (item.name === name) {
|
||||
item.selected = true;
|
||||
this._selected = item;
|
||||
}
|
||||
else {
|
||||
item.selected = false;
|
||||
}
|
||||
});
|
||||
|
||||
const selected = this.selectedItem;
|
||||
if (selected) {
|
||||
this.updateSelected(selected);
|
||||
}
|
||||
}
|
||||
|
||||
updateSelected(item: MenuItem) {
|
||||
Array.from(this.$menu.querySelectorAll("a")).forEach($anchor => {
|
||||
$anchor.classList.remove("selected");
|
||||
if (
|
||||
$anchor.parentElement?.id ===
|
||||
`menu-item__${item.name}`
|
||||
) {
|
||||
$anchor.classList.add("selected");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get items() { return this._items }
|
||||
|
||||
addItem(item: MenuItem) {
|
||||
this._items.push(item);
|
||||
}
|
||||
|
||||
attributeChangedCallback(k: string, p: string, c: string) {
|
||||
switch (k) {
|
||||
case "display-mode":
|
||||
if (!displayModes.includes(c as any)) {
|
||||
throw new Error(
|
||||
`Attribute "display-mode" must be one of ${
|
||||
JSON.stringify(displayModes)
|
||||
}`
|
||||
);
|
||||
}
|
||||
this.displayMode = c as DisplayMode || "stack";
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
init() {
|
||||
while (this.$menu.firstElementChild)
|
||||
this.$menu.removeChild(this.$menu.lastElementChild as Node);
|
||||
|
||||
this.items.forEach(item => {
|
||||
const $item =
|
||||
navItemTemplate.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
const $li = $item.querySelector("li") as HTMLLIElement;
|
||||
const $anchor = $item.querySelector("a") as HTMLAnchorElement;
|
||||
const $icon = $item.querySelector("i") as HTMLSpanElement;
|
||||
const $label = $item.querySelector("span") as HTMLSpanElement;
|
||||
|
||||
$li.id = `menu-item__${item.name}`;
|
||||
$anchor.href = item.href;
|
||||
if (item.spa) {
|
||||
$anchor.addEventListener("click", e => {
|
||||
e.preventDefault();
|
||||
window.history.pushState({}, "", $anchor.href);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
this.selected = item.name;
|
||||
});
|
||||
}
|
||||
if (item.selected) {
|
||||
$anchor.classList.add("selected");
|
||||
}
|
||||
$anchor.title = item.label;
|
||||
$icon.innerText = item.icon;
|
||||
$label.innerText = item.label;
|
||||
|
||||
this.$menu.appendChild($item);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
window.customElements.define("pj-nav", Nav);
|
||||
112
src/Pages/Pages.ts
Normal file
112
src/Pages/Pages.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
const template = document.createElement("template");
|
||||
template.innerHTML = `<slot id="slot"></slot>`;
|
||||
|
||||
export default class Pages extends HTMLElement {
|
||||
|
||||
private _content: DocumentFragment;
|
||||
$slot: HTMLSlotElement;
|
||||
classForHidden: string = "hidden";
|
||||
|
||||
attrForSelected?: string;
|
||||
fallbackSelection?: string;
|
||||
selected?: HTMLElement;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({mode: "open"});
|
||||
this._content =
|
||||
template.content.cloneNode(true) as
|
||||
DocumentFragment;
|
||||
this.$slot =
|
||||
this._content.getElementById("slot") as
|
||||
HTMLSlotElement;
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
this.shadowRoot?.appendChild(this._content);
|
||||
this.$slot.assignedElements().forEach($ => {
|
||||
// ($ as HTMLElement).style.display = "none";
|
||||
($ as HTMLElement).classList.add(this.classForHidden);
|
||||
});
|
||||
|
||||
const defaultSelection = this.getAttribute("default-selection");
|
||||
if (defaultSelection) {
|
||||
this.select(defaultSelection);
|
||||
}
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return [
|
||||
"attr-for-selected",
|
||||
"fallback-selection",
|
||||
"default-selection",
|
||||
"class-for-hidden",
|
||||
];
|
||||
}
|
||||
attributeChangedCallback(name: string, old: string, newv: string) {
|
||||
switch (name) {
|
||||
case "attr-for-selected":
|
||||
this.attrForSelected = newv;
|
||||
break;
|
||||
case "fallback-selection":
|
||||
this.fallbackSelection = newv;
|
||||
break;
|
||||
case "class-for-hidden":
|
||||
this.classForHidden = newv;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
selectByAttribute(items: HTMLElement[], id: string) {
|
||||
let selected;
|
||||
|
||||
if (!this.attrForSelected) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let item of items) {
|
||||
if (item.getAttribute(this.attrForSelected) === id) {
|
||||
// item.style.display = "";
|
||||
item.classList.remove(this.classForHidden);
|
||||
selected = item;
|
||||
} else {
|
||||
// item.style.display = "none";
|
||||
item.classList.add(this.classForHidden);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
select(id: string) {
|
||||
const items =
|
||||
this.$slot.assignedElements() as
|
||||
HTMLElement[];
|
||||
delete this.selected;
|
||||
|
||||
if (this.attrForSelected) {
|
||||
this.selected = this.selectByAttribute(items, id);
|
||||
if (!this.selected && this.fallbackSelection) {
|
||||
this.selected = this.selectByAttribute(items, this.fallbackSelection);
|
||||
}
|
||||
} else {
|
||||
// No `attrForSelected`; trying id == idx approach
|
||||
let idx = parseInt(id);
|
||||
|
||||
if (isNaN(idx)) {
|
||||
throw Error("No suitable `id` found.");
|
||||
}
|
||||
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (i === idx) {
|
||||
item.classList.remove(this.classForHidden);
|
||||
this.selected = item;
|
||||
}
|
||||
else {
|
||||
item.classList.add(this.classForHidden);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
window.customElements.define("pj-pages", Pages);
|
||||
126
src/Route/Route.ts
Normal file
126
src/Route/Route.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* class designed to take a `path` and a `pattern` and answer the questions:
|
||||
*
|
||||
* Am I the active route? (Answered by `this.active === true`)
|
||||
* if active:
|
||||
* Do I have sub-routes? (Answered by `this.tail !== void 0`)
|
||||
*
|
||||
* These are recursive: `this.tail` provides the next `path`
|
||||
* to a possible subroute.
|
||||
*
|
||||
* e.g., ```javascript
|
||||
* // window.location.pathname === "/posts/123/edit";
|
||||
* const route = new Route("/posts/:id");
|
||||
* if (route.active) {
|
||||
* if (route.tail) {
|
||||
* // handle subroute
|
||||
* const id = router.data.id;
|
||||
* const subroute = new Route("/:action", route.tail);
|
||||
* switch (subroute.data?.action) {
|
||||
* // ... ?
|
||||
* }
|
||||
* } else {
|
||||
* // ... ?
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* ```
|
||||
*/
|
||||
export default class Route {
|
||||
private _pattern: string = "";
|
||||
private _path: string = "";
|
||||
private _data: Record<string,string> = {};
|
||||
private _active: boolean = false;
|
||||
private _tail: string = "";
|
||||
|
||||
/**
|
||||
* When constructed, the instance will always have a value for both `path`
|
||||
* and `pattern`, but we only want to `init` once, so we don't set
|
||||
* `this.path`, but `this._path` to ensure that we only `init` once.
|
||||
*
|
||||
* After this, the instance will re-`init` whenever `this.path` or
|
||||
* `this.pattern` are set extrinsically.
|
||||
*/
|
||||
constructor(pattern?: string, path?: string) {
|
||||
this._path = path !== void 0
|
||||
? path
|
||||
: window.location.pathname;
|
||||
|
||||
this.pattern = pattern || this._pattern;
|
||||
}
|
||||
|
||||
get pattern() {
|
||||
return this._pattern;
|
||||
}
|
||||
|
||||
set pattern(x: string) {
|
||||
this._pattern = x;
|
||||
this._init();
|
||||
}
|
||||
|
||||
get path() {
|
||||
return this._path;
|
||||
}
|
||||
|
||||
set path(x: string) {
|
||||
this._path = x;
|
||||
this._init();
|
||||
}
|
||||
|
||||
/**
|
||||
* `init`'s job is to determine whether this route is active, and to
|
||||
* construct the tail based on the given pattern.
|
||||
*
|
||||
* A pattern takes the form "/foo/:bar/baz" or "/:foo" or "/foo", etc.
|
||||
* The tail is the part of the path that begins after the first part of the
|
||||
* path that matches the `pattern`.
|
||||
*
|
||||
* If it's not a match, `active` is false, and the tail accessor will
|
||||
* provide `null`.
|
||||
*
|
||||
* `data` will be cached from the route as it's parsed, even if ultimately
|
||||
* there's no match, but the `get data` accessor will provide `null` if the
|
||||
* route isn't `active`, so those "cached" values will be inert.
|
||||
*/
|
||||
private _init() {
|
||||
const pathParts = this.path.split("/");
|
||||
const patternParts = this.pattern.split("/");
|
||||
|
||||
this._data = {};
|
||||
this._tail = "";
|
||||
|
||||
this._active = patternParts.every(
|
||||
(x, idx) => {
|
||||
if (x.length > 1 && x.startsWith(":")) {
|
||||
const part = pathParts[idx];
|
||||
return (
|
||||
this._data[x.substring(1)] =
|
||||
part && decodeURIComponent(part)
|
||||
);
|
||||
} else {
|
||||
return x === pathParts[idx];
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
this._tail = pathParts.slice(patternParts.length).join("/");
|
||||
}
|
||||
|
||||
get active() {
|
||||
return this._active;
|
||||
}
|
||||
|
||||
get data() {
|
||||
return this._active
|
||||
? this._data
|
||||
: null;
|
||||
}
|
||||
|
||||
get tail() {
|
||||
return !this._active
|
||||
? null
|
||||
: !this._tail.length
|
||||
? null
|
||||
: `/${this._tail}`;
|
||||
}
|
||||
}
|
||||
41
src/utils.ts
Normal file
41
src/utils.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
export class Mutex {
|
||||
|
||||
private _current: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
this._current = Promise.resolve();
|
||||
}
|
||||
|
||||
lock() {
|
||||
let _resolve: () => void;
|
||||
|
||||
const p = new Promise(resolve => {
|
||||
_resolve = () => resolve(void 0);
|
||||
});
|
||||
// Caller gets a promise that resolves when the current outstanding
|
||||
// lock resolves
|
||||
const rv = this._current.then(() => _resolve);
|
||||
// Don't allow the next request until the new promise is done
|
||||
this._current = p as Promise<void>;
|
||||
// Return the new promise
|
||||
return rv;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
export const debounce = (func: CallableFunction, ms: number) => {
|
||||
let timeout: any;
|
||||
return (...args: any[]) => {
|
||||
const later = () => {
|
||||
timeout = null;
|
||||
func(...args);
|
||||
};
|
||||
clearTimeout(timeout);
|
||||
timeout = setTimeout(later, ms);
|
||||
};
|
||||
};
|
||||
|
||||
export const redirect = (path: string) => {
|
||||
window.history.pushState({}, "", path);
|
||||
window.dispatchEvent(new Event("popstate"));
|
||||
};
|
||||
Reference in New Issue
Block a user