From 9891ba5b49e9720ae68be52a8775bc8415cb2d29 Mon Sep 17 00:00:00 2001 From: Tom Brennan Date: Mon, 22 Apr 2024 20:46:36 -0400 Subject: [PATCH] revise with layout, etc. --- .gitignore | 2 +- .swcrc | 9 ++ Makefile | 31 +++-- index.html | 90 -------------- index.template.html | 81 +++++++++++++ package-lock.json | 2 +- src/App/App.html | 178 +++++++++++++++++++++++++++ src/App/App.ts | 192 +++++++++++++++++++++++++++++ src/ECGTicker/ECGTicker.ts | 42 +++++++ src/Layout/Layout.html | 242 +++++++++++++++++++++++++++++++++++++ src/Layout/Layout.ts | 222 ++++++++++++++++++++++++++++++++++ src/MyCard/MyCard.html | 46 +++++++ src/{ => MyCard}/MyCard.ts | 8 +- src/Nav/Nav.html | 126 +++++++++++++++++++ src/Nav/Nav.ts | 188 ++++++++++++++++++++++++++++ src/Pages/Pages.ts | 112 +++++++++++++++++ src/Route/Route.ts | 126 +++++++++++++++++++ src/utils.ts | 41 +++++++ 18 files changed, 1637 insertions(+), 101 deletions(-) create mode 100644 .swcrc delete mode 100644 index.html create mode 100644 index.template.html create mode 100644 src/App/App.html create mode 100644 src/App/App.ts create mode 100644 src/ECGTicker/ECGTicker.ts create mode 100644 src/Layout/Layout.html create mode 100644 src/Layout/Layout.ts create mode 100644 src/MyCard/MyCard.html rename src/{ => MyCard}/MyCard.ts (94%) create mode 100644 src/Nav/Nav.html create mode 100644 src/Nav/Nav.ts create mode 100644 src/Pages/Pages.ts create mode 100644 src/Route/Route.ts create mode 100644 src/utils.ts diff --git a/.gitignore b/.gitignore index b51ea71..b947077 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,2 @@ node_modules/ -build/ \ No newline at end of file +dist/ diff --git a/.swcrc b/.swcrc new file mode 100644 index 0000000..d1d8d8f --- /dev/null +++ b/.swcrc @@ -0,0 +1,9 @@ +{ + "jsc": { + "parser": { + "syntax": "typescript" + }, + "target": "esnext" + }, + "isModule": true +} \ No newline at end of file diff --git a/Makefile b/Makefile index f88d4d2..5e6ad60 100644 --- a/Makefile +++ b/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 \ No newline at end of file +.PHONY: all clean serve \ No newline at end of file diff --git a/index.html b/index.html deleted file mode 100644 index e272e50..0000000 --- a/index.html +++ /dev/null @@ -1,90 +0,0 @@ - - - - MEDTRACE: Case - Frontend Developer - - - - - - - - - -
- - Custom headline - - - hello world -
- - - - \ No newline at end of file diff --git a/index.template.html b/index.template.html new file mode 100644 index 0000000..3b7a443 --- /dev/null +++ b/index.template.html @@ -0,0 +1,81 @@ + + + + + + + MEDTRACE: Case - Frontend Developer + + + + + + + + + ${TEMPLATES} + + + + \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index c8011cd..7793633 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "medtrace", + "name": "web", "lockfileVersion": 3, "requires": true, "packages": { diff --git a/src/App/App.html b/src/App/App.html new file mode 100644 index 0000000..0f4a838 --- /dev/null +++ b/src/App/App.html @@ -0,0 +1,178 @@ + \ No newline at end of file diff --git a/src/App/App.ts b/src/App/App.ts new file mode 100644 index 0000000..693115a --- /dev/null +++ b/src/App/App.ts @@ -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"); + } + } +}); \ No newline at end of file diff --git a/src/ECGTicker/ECGTicker.ts b/src/ECGTicker/ECGTicker.ts new file mode 100644 index 0000000..a299f91 --- /dev/null +++ b/src/ECGTicker/ECGTicker.ts @@ -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 {}; \ No newline at end of file diff --git a/src/Layout/Layout.html b/src/Layout/Layout.html new file mode 100644 index 0000000..855a26c --- /dev/null +++ b/src/Layout/Layout.html @@ -0,0 +1,242 @@ + \ No newline at end of file diff --git a/src/Layout/Layout.ts b/src/Layout/Layout.ts new file mode 100644 index 0000000..d31f041 --- /dev/null +++ b/src/Layout/Layout.ts @@ -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); \ No newline at end of file diff --git a/src/MyCard/MyCard.html b/src/MyCard/MyCard.html new file mode 100644 index 0000000..0f504e2 --- /dev/null +++ b/src/MyCard/MyCard.html @@ -0,0 +1,46 @@ + + \ No newline at end of file diff --git a/src/MyCard.ts b/src/MyCard/MyCard.ts similarity index 94% rename from src/MyCard.ts rename to src/MyCard/MyCard.ts index 7545516..c713d82 100644 --- a/src/MyCard.ts +++ b/src/MyCard/MyCard.ts @@ -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)); } } -}); \ No newline at end of file +}); + +export {}; \ No newline at end of file diff --git a/src/Nav/Nav.html b/src/Nav/Nav.html new file mode 100644 index 0000000..9c2ccf5 --- /dev/null +++ b/src/Nav/Nav.html @@ -0,0 +1,126 @@ + \ No newline at end of file diff --git a/src/Nav/Nav.ts b/src/Nav/Nav.ts new file mode 100644 index 0000000..512f6c1 --- /dev/null +++ b/src/Nav/Nav.ts @@ -0,0 +1,188 @@ +const template = document.getElementById("nav-template") as + HTMLTemplateElement; + +const navItemTemplate = document.createElement("template"); +navItemTemplate.innerHTML = ` + +`; + +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 = {} 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); \ No newline at end of file diff --git a/src/Pages/Pages.ts b/src/Pages/Pages.ts new file mode 100644 index 0000000..b44638e --- /dev/null +++ b/src/Pages/Pages.ts @@ -0,0 +1,112 @@ +const template = document.createElement("template"); +template.innerHTML = ``; + +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); \ No newline at end of file diff --git a/src/Route/Route.ts b/src/Route/Route.ts new file mode 100644 index 0000000..791f739 --- /dev/null +++ b/src/Route/Route.ts @@ -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 = {}; + 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}`; + } +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..aa0c23b --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,41 @@ +export class Mutex { + + private _current: Promise; + + 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; + // 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")); +}; \ No newline at end of file