revise with layout, etc.
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,2 @@
|
|||||||
node_modules/
|
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
|
BUILD_DIR := ./dist
|
||||||
npx swc ./src/MyCard.ts -o ./build/MyCard.js
|
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:
|
all: $(OBJS)
|
||||||
mkdir -p ./build
|
|
||||||
|
$(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:
|
./node_modules:
|
||||||
npm install
|
npm install
|
||||||
|
|
||||||
serve: ./build/MyCard.js
|
serve: all
|
||||||
npx serve
|
npx serve -s $(BUILD_DIR)
|
||||||
|
|
||||||
clean:
|
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,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"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
|
* This could also be imported as a string or written here and attached to the
|
||||||
* `document.body` as a `template` element, like
|
* `document.body` as a `template` element, like
|
||||||
@@ -47,3 +51,5 @@ window.customElements.define("my-card", class extends HTMLElement {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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