├── .github └── workflows │ └── update.yml ├── .gitignore ├── CODEOWNERS ├── LICENSE ├── README.md ├── assets └── font │ ├── RedHatDisplay.woff2 │ └── font.css ├── components ├── appendBody.ts ├── entry.ts ├── form │ ├── button.ts │ ├── checkbox.ts │ ├── dropdown.ts │ ├── form.ts │ ├── inlineInput.ts │ └── input.ts ├── icons.ts ├── image.ts ├── menu.ts ├── misc │ ├── mediaQueryRef.ts │ ├── mobileQuery.ts │ └── themeQuery.ts ├── mod.ts ├── spinner.ts ├── stacking │ ├── dialogContainer.ts │ ├── sheetHeader.ts │ └── sheets.ts ├── styles.ts ├── table.ts └── theme.ts ├── core ├── color.ts ├── components.ts ├── cssTemplate.ts ├── layout │ ├── async.ts │ ├── box.ts │ ├── content.ts │ ├── empty.ts │ ├── grid.ts │ ├── label.ts │ ├── list.ts │ ├── mod.ts │ ├── popover.ts │ └── slot.ts ├── lazy.ts ├── mod.ts ├── state.ts └── types.ts ├── deno.jsonc ├── extended ├── filePicker.ts ├── fromFormEntries.ts ├── iterableWeakMap.ts ├── keyValueStore.ts ├── mod.ts ├── network.ts ├── reorder.ts ├── scheduler.ts ├── stableRequests.ts └── stableWebSockets.ts ├── mod.ts ├── navigation ├── Menu.ts ├── Navigation.ts ├── Page.ts ├── Route.ts └── mod.ts └── src └── components ├── Image.css ├── Image.ts ├── Switch.css ├── Switch.ts ├── Tab.css ├── Tab.ts ├── Taglist.ts └── taglist.css /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update Deno Dependencies 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 0 * * *" # Human: 12am every day 7 | 8 | permissions: 9 | contents: write 10 | pull-requests: write 11 | 12 | jobs: 13 | update: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@main 17 | - uses: hasundue/molt-action@v1 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | bin 3 | .DS_Store 4 | .vscode -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | * @lucsoft 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Creative Commons Legal Code 2 | 3 | CC0 1.0 Universal 4 | 5 | CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE 6 | LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN 7 | ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS 8 | INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES 9 | REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS 10 | PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM 11 | THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED 12 | HEREUNDER. 13 | 14 | Statement of Purpose 15 | 16 | The laws of most jurisdictions throughout the world automatically confer 17 | exclusive Copyright and Related Rights (defined below) upon the creator 18 | and subsequent owner(s) (each and all, an "owner") of an original work of 19 | authorship and/or a database (each, a "Work"). 20 | 21 | Certain owners wish to permanently relinquish those rights to a Work for 22 | the purpose of contributing to a commons of creative, cultural and 23 | scientific works ("Commons") that the public can reliably and without fear 24 | of later claims of infringement build upon, modify, incorporate in other 25 | works, reuse and redistribute as freely as possible in any form whatsoever 26 | and for any purposes, including without limitation commercial purposes. 27 | These owners may contribute to the Commons to promote the ideal of a free 28 | culture and the further production of creative, cultural and scientific 29 | works, or to gain reputation or greater distribution for their Work in 30 | part through the use and efforts of others. 31 | 32 | For these and/or other purposes and motivations, and without any 33 | expectation of additional consideration or compensation, the person 34 | associating CC0 with a Work (the "Affirmer"), to the extent that he or she 35 | is an owner of Copyright and Related Rights in the Work, voluntarily 36 | elects to apply CC0 to the Work and publicly distribute the Work under its 37 | terms, with knowledge of his or her Copyright and Related Rights in the 38 | Work and the meaning and intended legal effect of CC0 on those rights. 39 | 40 | 1. Copyright and Related Rights. A Work made available under CC0 may be 41 | protected by copyright and related or neighboring rights ("Copyright and 42 | Related Rights"). Copyright and Related Rights include, but are not 43 | limited to, the following: 44 | 45 | i. the right to reproduce, adapt, distribute, perform, display, 46 | communicate, and translate a Work; 47 | ii. moral rights retained by the original author(s) and/or performer(s); 48 | iii. publicity and privacy rights pertaining to a person's image or 49 | likeness depicted in a Work; 50 | iv. rights protecting against unfair competition in regards to a Work, 51 | subject to the limitations in paragraph 4(a), below; 52 | v. rights protecting the extraction, dissemination, use and reuse of data 53 | in a Work; 54 | vi. database rights (such as those arising under Directive 96/9/EC of the 55 | European Parliament and of the Council of 11 March 1996 on the legal 56 | protection of databases, and under any national implementation 57 | thereof, including any amended or successor version of such 58 | directive); and 59 | vii. other similar, equivalent or corresponding rights throughout the 60 | world based on applicable law or treaty, and any national 61 | implementations thereof. 62 | 63 | 2. Waiver. To the greatest extent permitted by, but not in contravention 64 | of, applicable law, Affirmer hereby overtly, fully, permanently, 65 | irrevocably and unconditionally waives, abandons, and surrenders all of 66 | Affirmer's Copyright and Related Rights and associated claims and causes 67 | of action, whether now known or unknown (including existing as well as 68 | future claims and causes of action), in the Work (i) in all territories 69 | worldwide, (ii) for the maximum duration provided by applicable law or 70 | treaty (including future time extensions), (iii) in any current or future 71 | medium and for any number of copies, and (iv) for any purpose whatsoever, 72 | including without limitation commercial, advertising or promotional 73 | purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each 74 | member of the public at large and to the detriment of Affirmer's heirs and 75 | successors, fully intending that such Waiver shall not be subject to 76 | revocation, rescission, cancellation, termination, or any other legal or 77 | equitable action to disrupt the quiet enjoyment of the Work by the public 78 | as contemplated by Affirmer's express Statement of Purpose. 79 | 80 | 3. Public License Fallback. Should any part of the Waiver for any reason 81 | be judged legally invalid or ineffective under applicable law, then the 82 | Waiver shall be preserved to the maximum extent permitted taking into 83 | account Affirmer's express Statement of Purpose. In addition, to the 84 | extent the Waiver is so judged Affirmer hereby grants to each affected 85 | person a royalty-free, non transferable, non sublicensable, non exclusive, 86 | irrevocable and unconditional license to exercise Affirmer's Copyright and 87 | Related Rights in the Work (i) in all territories worldwide, (ii) for the 88 | maximum duration provided by applicable law or treaty (including future 89 | time extensions), (iii) in any current or future medium and for any number 90 | of copies, and (iv) for any purpose whatsoever, including without 91 | limitation commercial, advertising or promotional purposes (the 92 | "License"). The License shall be deemed effective as of the date CC0 was 93 | applied by Affirmer to the Work. Should any part of the License for any 94 | reason be judged legally invalid or ineffective under applicable law, such 95 | partial invalidity or ineffectiveness shall not invalidate the remainder 96 | of the License, and in such case Affirmer hereby affirms that he or she 97 | will not (i) exercise any of his or her remaining Copyright and Related 98 | Rights in the Work or (ii) assert any associated claims and causes of 99 | action with respect to the Work, in either case contrary to Affirmer's 100 | express Statement of Purpose. 101 | 102 | 4. Limitations and Disclaimers. 103 | 104 | a. No trademark or patent rights held by Affirmer are waived, abandoned, 105 | surrendered, licensed or otherwise affected by this document. 106 | b. Affirmer offers the Work as-is and makes no representations or 107 | warranties of any kind concerning the Work, express, implied, 108 | statutory or otherwise, including without limitation warranties of 109 | title, merchantability, fitness for a particular purpose, non 110 | infringement, or the absence of latent or other defects, accuracy, or 111 | the present or absence of errors, whether or not discoverable, all to 112 | the greatest extent permissible under applicable law. 113 | c. Affirmer disclaims responsibility for clearing rights of other persons 114 | that may apply to the Work or any use thereof, including without 115 | limitation any person's Copyright and Related Rights in the Work. 116 | Further, Affirmer disclaims responsibility for obtaining any necessary 117 | consents, permissions or other rights required for any use of the 118 | Work. 119 | d. Affirmer understands and acknowledges that Creative Commons is not a 120 | party to this document and has no duty or obligation with respect to 121 | this CC0 or use of the Work. 122 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | # WebGen 2.0 4 | 5 | A SwiftUI-like Web library 6 | 7 | ## Getting Started 8 | 9 | ```ts 10 | // Create a mod.ts file 11 | import { appendBody, Label, WebGenTheme } from "https://deno.land/x/webgen/mod.ts"; 12 | 13 | appendBody( 14 | WebGenTheme( 15 | Label("Hello World!") 16 | ) 17 | ); 18 | ``` 19 | 20 | ```ts 21 | // Create a serve.ts file 22 | import { serve } from "https://deno.land/x/esbuild_serve/mod.ts"; 23 | 24 | serve({ 25 | pages: { 26 | "index": "mod.ts", 27 | }, 28 | }); 29 | ``` 30 | 31 | ``` 32 | deno run -A serve.ts 33 | ``` 34 | 35 | Done! Have fun! More docs will follow 36 | 37 | ## Architecture 38 | 39 | WebGen is build around the 4 main components: 40 | 41 | - **Core API**: Bringing layouting, the webgen component pattern and state management. 42 | - **Navigation API**: Build Applications with multiple pages and navigation based on the global URL and the Navigation API. 43 | - **Components API**: A set of prebuild components like Button, Checkbox, Image, List, Sheets. 44 | - **Extended API**: Provides additional features like a scheduler, a network issue resilient fetch/websocket API. -------------------------------------------------------------------------------- /assets/font/RedHatDisplay.woff2: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucsoft/WebGen/26fb2600a1b6705d028da51f8c6a58d372241dc1/assets/font/RedHatDisplay.woff2 -------------------------------------------------------------------------------- /assets/font/font.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family: "Red Hat Display"; 3 | src: url(./RedHatDisplay.woff2) format("woff2-variations"); 4 | font-weight: 300 900; 5 | font-display: swap; 6 | } -------------------------------------------------------------------------------- /components/appendBody.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "../core/components.ts"; 2 | 3 | export function appendBody(component: Component) { 4 | document.body.append(component.draw()); 5 | } -------------------------------------------------------------------------------- /components/entry.ts: -------------------------------------------------------------------------------- 1 | import { asRef, asWebGenComponent, Box, Color, Component, css, Grid, HTMLComponent, listen } from "../core/mod.ts"; 2 | import { MaterialIcon } from "./icons.ts"; 3 | import { Spinner } from "./spinner.ts"; 4 | 5 | @asWebGenComponent("entry") 6 | export class EntryComponent extends HTMLComponent { 7 | #button = document.createElement("button"); 8 | #loading = asRef(false); 9 | #canClick = asRef(false); 10 | constructor(content: Component) { 11 | super(); 12 | this.#button.append(Grid( 13 | content, 14 | Box(this.#canClick.map(canClick => canClick ? Box(this.#loading.map(loading => loading ? Spinner() : MaterialIcon("arrow_forward_ios"))) : [])) 15 | ) 16 | .setTemplateColumns("auto") 17 | .setAutoFlow("column") 18 | .setAutoColumn("max-content") 19 | .setAlignItems("center") 20 | .setWidth("100%") 21 | .draw()); 22 | this.shadowRoot!.append(this.#button); 23 | 24 | this.addWatch(() => listen(() => { 25 | this.#button.disabled = !this.#canClick.value || this.#loading.value; 26 | })); 27 | 28 | this.shadowRoot!.adoptedStyleSheets.push(css` 29 | :host { 30 | display: grid 31 | } 32 | button { 33 | all: unset; 34 | display: grid; 35 | background-color: ${Color.reverseNeutral.mix(Color.primary, 5)}; 36 | place-items: center start; 37 | box-sizing: border-box; 38 | padding: 0 25px; 39 | transition: all 250ms ease; 40 | cursor: pointer; 41 | box-shadow: var(--wg-shadow-1); 42 | border-radius: var(--wg-entry-radius, var(--wg-radius-large)); 43 | margin: 5px 0; 44 | outline: none; 45 | } 46 | button:not(:disabled):focus-visible, 47 | button:not(:disabled):hover { 48 | background-color: ${Color.reverseNeutral.mix(Color.primary, 12)}; 49 | box-shadow: var(--wg-shadow-4); 50 | transform: translate(0, -2px); 51 | } 52 | button:not(:disabled):active { 53 | transform: translate(0, 0); 54 | box-shadow: var(--wg-shadow-3); 55 | } 56 | `); 57 | } 58 | 59 | make() { 60 | const obj = { 61 | ...super.make(), 62 | addPrefix: (component: Component) => { this.#button.prepend(component.draw()); return obj; }, 63 | addSuffix: (component: Component) => { this.#button.append(component.draw()); return obj; }, 64 | onClick: (callback: () => void) => { 65 | this.#canClick.value = true; 66 | super.make().onClick(callback); 67 | return obj; 68 | }, 69 | onPromiseClick: (callback: () => Promise) => { 70 | this.#canClick.value = true; 71 | 72 | super.make().onClick(() => { 73 | if (this.#loading.value) return; 74 | this.#loading.value = true; 75 | callback() 76 | .catch(() => { }) 77 | .finally(() => { 78 | this.#loading.value = false; 79 | }); 80 | }); 81 | 82 | return obj; 83 | } 84 | }; 85 | return obj; 86 | } 87 | } 88 | 89 | export function Entry(content: Component) { 90 | return new EntryComponent(content).make(); 91 | } 92 | -------------------------------------------------------------------------------- /components/form/button.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asRef, asWebGenComponent, Color, Component, css, HTMLComponent, Label, Refable, Reference } from "../../core/mod.ts"; 2 | import { Spinner } from "../spinner.ts"; 3 | 4 | export enum ButtonMode { 5 | Primary = "primary", 6 | Secondary = "secondary", 7 | Text = "text" 8 | } 9 | 10 | @asWebGenComponent("button") 11 | export class ButtonComponent extends HTMLComponent { 12 | #disabled = asRef(false); 13 | #buttonInternalBg = new Color("var(--wg-internal-button-bg)"); 14 | #buttonInternalFg = new Color("var(--wg-internal-button-fg)"); 15 | #buttonBg = new Color("var(--wg-button-background-color, var(--wg-primary))"); 16 | #buttonFg = new Color("var(--wg-button-text-color, var(--wg-primary-text))"); 17 | #button = document.createElement("button"); 18 | #loading = asRef(false); 19 | 20 | constructor(label: Refable, mode: Reference = asRef(ButtonMode.Primary)) { 21 | super(); 22 | 23 | this.shadowRoot?.append(this.#button); 24 | 25 | this.useListener(mode, (newMode, oldMode) => { 26 | if (oldMode) { 27 | this.#button.classList.remove(oldMode); 28 | } 29 | this.#button.setAttribute("mode", newMode); 30 | }); 31 | 32 | this.#button.append(Label(alwaysRef(label)).addClass("content").draw()); 33 | 34 | this.useListener(this.#disabled, (disabled) => { 35 | this.#button.disabled = disabled; 36 | }); 37 | 38 | const spinner = Spinner().draw(); 39 | this.useListener(this.#loading, (loading) => { 40 | if (loading) { 41 | this.#button.append(spinner); 42 | this.#button.classList.add("loading"); 43 | } 44 | else { 45 | spinner.remove(); 46 | this.#button.classList.remove("loading"); 47 | } 48 | }); 49 | 50 | this.shadowRoot?.adoptedStyleSheets.push(css` 51 | button { 52 | all: unset; 53 | display: grid; 54 | place-items: center; 55 | grid-auto-flow: column; 56 | --wg-internal-button-bg: ${this.#buttonBg.toString()}; 57 | --wg-internal-button-fg: ${this.#buttonFg.toString()}; 58 | background-color: var(--wg-internal-button-bg); 59 | color: ${this.#buttonInternalFg.toString()}; 60 | padding: var(--wg-button-padding, 0 10px); 61 | height: var(--wg-button-height, 36px); 62 | font-weight: var(--wg-button-font-weight, var(--wg-fontweight-semibold)); 63 | font-size: var(--wg-button-font-size, var(--wg-fontsize-sm)); 64 | border-radius: var(--wg-button-border-radius, var(--wg-radius-tiny)); 65 | box-shadow: var(--wg-button-box-shadow); 66 | outline: 0px solid ${this.#buttonInternalBg.mix(Color.transparent, 50)}; 67 | transition: all 250ms ease; 68 | user-select: none; 69 | width: 100%; 70 | justify-content: center; 71 | box-sizing: border-box; 72 | } 73 | button>.content { 74 | padding: var(--wg-button-text-padding, 0 7px); 75 | line-height: 0.9; 76 | } 77 | @supports (text-box: trim-both cap alphabetic) { 78 | button>.content { 79 | line-height: unset; 80 | text-box: trim-both cap alphabetic; 81 | } 82 | } 83 | button:not(:disabled) { 84 | cursor: pointer; 85 | } 86 | button:not(:disabled):hover, 87 | button:not(:disabled):focus-visible { 88 | outline: 5px solid ${this.#buttonInternalBg.mix(Color.transparent, 50)}; 89 | transform: translate(0, -2px); 90 | } 91 | button:not(:disabled):active { 92 | outline: 3px solid ${this.#buttonInternalBg.mix(Color.transparent, 50)}; 93 | transform: translate(0, 0px); 94 | } 95 | 96 | button[mode="secondary"] { 97 | --wg-internal-button-bg: ${this.#buttonBg.mix(Color.transparent, 90)}; 98 | --wg-internal-button-fg: ${this.#buttonBg.toString()}; 99 | } 100 | button[mode="secondary"]:hover, 101 | button[mode="secondary"]:focus-visible { 102 | --wg-internal-button-bg: ${this.#buttonBg.mix(Color.transparent, 80)}; 103 | outline: 0px solid ${this.#buttonInternalBg.mix(Color.transparent, 50)}; 104 | } 105 | 106 | button[mode="text"] { 107 | --wg-internal-button-bg: transparent; 108 | --wg-internal-button-fg: ${this.#buttonBg.toString()}; 109 | } 110 | 111 | button[mode="text"]:hover, 112 | button[mode="text"]:focus-visible { 113 | --wg-internal-button-bg: ${this.#buttonBg.mix(Color.transparent, 80)}; 114 | outline: 0px solid ${this.#buttonInternalBg.mix(Color.transparent, 50)}; 115 | } 116 | button:disabled { 117 | --wg-internal-button-bg: var(--wg-button-disabled-color, hsl(0deg 0% 20%)); 118 | --wg-internal-button-fg: var(--wg-button-disabled-text-color, hsl(0deg 0% 40%)); 119 | } 120 | button.loading > :not(wg-spinner) { 121 | opacity: 0; 122 | } 123 | wg-spinner { 124 | position: absolute; 125 | scale: .8; 126 | } 127 | `); 128 | } 129 | 130 | override make() { 131 | const obj = { 132 | ...super.make(), 133 | addPrefix: (component: Component) => { this.#button.prepend(component.draw()); return obj; }, 134 | addSuffix: (component: Component) => { this.#button.append(component.draw()); return obj; }, 135 | setDisabled: (disabled: Refable) => { 136 | this.useListener(alwaysRef(disabled), (newDisabled) => { 137 | this.#disabled.value = newDisabled; 138 | }); 139 | return obj; 140 | }, 141 | setCustomColor: (color: Color) => { 142 | this.style.setProperty("--wg-primary", color.toString()); 143 | return obj; 144 | }, 145 | onPromiseClick: (callback: () => Promise) => { 146 | super.make().onClick(() => { 147 | if (this.#loading.value) return; 148 | this.#loading.value = true; 149 | callback() 150 | .catch(() => { }) 151 | .finally(() => { 152 | this.#loading.value = false; 153 | }); 154 | }); 155 | 156 | return obj; 157 | } 158 | }; 159 | return obj; 160 | } 161 | } 162 | 163 | export function PrimaryButton(label: Refable) { 164 | return new ButtonComponent(label, asRef(ButtonMode.Primary)).make(); 165 | } 166 | 167 | export function SecondaryButton(label: Refable) { 168 | return new ButtonComponent(label, asRef(ButtonMode.Secondary)).make(); 169 | } 170 | 171 | export function TextButton(label: Refable) { 172 | return new ButtonComponent(label, asRef(ButtonMode.Text)).make(); 173 | } -------------------------------------------------------------------------------- /components/form/checkbox.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asRef, asWebGenComponent, Color, css, HTMLComponent, WriteSignal, type Refable } from "../../core/mod.ts"; 2 | import { MaterialIcon } from "../icons.ts"; 3 | 4 | export type CheckboxValue = boolean | 'intermediate'; 5 | 6 | @asWebGenComponent("checkbox") 7 | export class CheckboxComponent extends HTMLComponent { 8 | #disabled = asRef(false); 9 | #inputBg = new Color("var(--wg-checkbox-background-color, var(--wg-primary))"); 10 | #input = document.createElement("input"); 11 | constructor(mode: WriteSignal) { 12 | super(); 13 | 14 | this.#input.type = "checkbox"; 15 | 16 | const iconContainer = document.createElement("div"); 17 | this.shadowRoot?.append(iconContainer); 18 | this.shadowRoot?.append(this.#input); 19 | 20 | this.useListener(mode, (checked) => { 21 | this.#input.checked = checked === true; 22 | this.#input.indeterminate = checked === 'intermediate'; 23 | if (checked) 24 | this.setAttribute("checked", String(checked)); 25 | else 26 | this.removeAttribute("checked"); 27 | }); 28 | 29 | iconContainer.append(MaterialIcon(mode.map(mode => { 30 | if (mode === true) return "check_small"; 31 | if (mode === "intermediate") return "check_indeterminate_small"; 32 | return ""; 33 | })).draw()); 34 | 35 | this.useEventListener(this.#input, "change", () => { 36 | mode.value = this.#input.checked; 37 | }); 38 | 39 | this.useListener(this.#disabled, (disabled) => { 40 | this.#input.disabled = disabled; 41 | if (disabled) 42 | this.setAttribute("disabled", ""); 43 | else 44 | this.removeAttribute("disabled"); 45 | }); 46 | 47 | this.shadowRoot?.adoptedStyleSheets.push(css` 48 | :host { 49 | display: grid; 50 | background-color: ${this.#inputBg.mix(Color.transparent, 95)}; 51 | width: var(--wg-checkbox-size, 25px); 52 | height: var(--wg-checkbox-size, 25px); 53 | grid-template: 100% / 100%; 54 | transition: all 250ms ease; 55 | color: var(--wg-primary-text); 56 | border-radius: var(--wg-checkbox-border-radius, var(--wg-radius-tiny)); 57 | user-select: none; 58 | } 59 | :host(:focus-within), 60 | :host(:hover) { 61 | background-color: ${this.#inputBg.mix(Color.transparent, 85)}; 62 | } 63 | :host(:hover) { 64 | transform: translate(0, -2px); 65 | } 66 | 67 | :host([checked]:focus-within), 68 | :host([checked]:hover) { 69 | outline: 5px solid ${this.#inputBg.mix(Color.transparent, 50)}; 70 | } 71 | 72 | :host(:active) { 73 | transform: translate(0, 0); 74 | } 75 | :host([checked]:active) { 76 | outline: 3px solid ${this.#inputBg.mix(Color.transparent, 50)}; 77 | } 78 | :host([checked]) { 79 | background-color: ${this.#inputBg.toString()}; 80 | } 81 | 82 | :host([disabled]) { 83 | background-color: var(--wg-checkbox-disabled-color, hsl(0deg 0% 20%)); 84 | color: var(--wg-checkbox-disabled-text-color, hsl(0deg 0% 40%)); 85 | transform: none; 86 | } 87 | 88 | input { 89 | appearance: none; 90 | outline: none; 91 | margin: -5px; 92 | } 93 | * { 94 | grid-area: 1 / 1; 95 | } 96 | div { 97 | display: grid; 98 | place-items: center; 99 | pointer-events: none; 100 | } 101 | 102 | :host(:not([disabled])) input { 103 | cursor: pointer; 104 | } 105 | `); 106 | } 107 | 108 | override make() { 109 | const obj = { 110 | ...super.make(), 111 | onClick: (action: (event: Event) => void) => { 112 | this.useEventListener(this.#input, "click", action); 113 | return obj; 114 | }, 115 | setDisabled: (disabled: Refable = true) => { 116 | this.useListener(alwaysRef(disabled), (newDisabled) => { 117 | this.#disabled.value = newDisabled; 118 | }); 119 | return obj; 120 | }, 121 | }; 122 | return obj; 123 | } 124 | } 125 | 126 | export function Checkbox(mode: WriteSignal) { 127 | return new CheckboxComponent(mode).make(); 128 | } -------------------------------------------------------------------------------- /components/form/dropdown.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asRef, asWebGenComponent, Component, css, HTMLComponent, Refable, Reference, WriteSignal } from "../../core/mod.ts"; 2 | import { MaterialIcon } from "../icons.ts"; 3 | import { SearchEngine } from "../menu.ts"; 4 | import { Menu } from "../mod.ts"; 5 | import { TextInput } from "./input.ts"; 6 | 7 | @asWebGenComponent("dropdown") 8 | class DropDownComponent extends HTMLComponent { 9 | #disabled = asRef(false); 10 | #invalid = asRef(false); 11 | #valueRender = asRef((value: string) => value); 12 | #menu: ReturnType; 13 | 14 | constructor(dropdown: Reference, selectedItem: Reference, label: Reference) { 15 | super(); 16 | let isOpen = false; 17 | this.#menu = Menu(dropdown) 18 | .setValueRenderer(this.#valueRender) 19 | .onItemClick((item) => { 20 | selectedItem.value = item; 21 | this.#menu.draw().hidePopover(); 22 | }); 23 | this.useEventListener(this.#menu.draw(), "toggle", (event) => { 24 | isOpen = (event).newState === "open"; 25 | }); 26 | this.#menu.setAttribute("popover", ""); 27 | 28 | const texbox = TextInput(selectedItem.map(item => item === undefined ? label.value : this.#valueRender.value(item)) as WriteSignal, selectedItem.map(item => item === undefined ? "" : label.value)) 29 | .setReadOnly() 30 | .setDisabled(this.#disabled) 31 | .addSuffix(MaterialIcon(this.#menu.focusedState().map(open => open ? "arrow_drop_up" : "arrow_drop_down")).setCssStyle("gridColumn", "3")) 32 | .onClick(() => { 33 | if (this.#disabled.value) return; 34 | if (isOpen) 35 | return this.#menu.draw().hidePopover(); 36 | this.#menu.clearSearch(); 37 | this.#menu.draw().showPopover(); 38 | this.#menu.focusedState().value = true; 39 | }) 40 | .draw(); 41 | 42 | this.useEventListener(texbox, "keyup", (event) => { 43 | if (this.#disabled.value) return; 44 | if ((event as KeyboardEvent).key === "Enter") { 45 | if (isOpen) 46 | return this.#menu.draw().hidePopover(); 47 | this.#menu.clearSearch(); 48 | this.#menu.draw().showPopover(); 49 | this.#menu.focusedState().value = true; 50 | } 51 | }); 52 | 53 | this.shadowRoot!.append( 54 | texbox, 55 | this.#menu.draw() 56 | ); 57 | 58 | this.shadowRoot!.adoptedStyleSheets.push(css` 59 | :host { 60 | position: relative; 61 | } 62 | wg-input { 63 | anchor-name: --anchor-dropdown; 64 | } 65 | wg-menu { 66 | position: fixed; 67 | inset: unset; 68 | overflow: unset; 69 | bottom: 1.5rem; 70 | padding: 0; 71 | background: none; 72 | border: none; 73 | width: auto; 74 | height: auto; 75 | position-anchor: --anchor-dropdown; 76 | top: anchor(bottom); 77 | left: anchor(left); 78 | } 79 | `); 80 | } 81 | 82 | override make() { 83 | const obj = { 84 | ...super.make(), 85 | setValueRender: (valueRender: (value: string) => string) => { 86 | this.#valueRender.value = valueRender; 87 | return obj; 88 | }, 89 | setSearchEngine: (searchEngine: SearchEngine) => { 90 | this.#menu.setSearchEngine(searchEngine); 91 | return obj; 92 | }, 93 | setDisabled: (disabled: boolean) => { 94 | this.#disabled.value = disabled; 95 | return obj; 96 | }, 97 | setInvalid: (invalid: boolean) => { 98 | this.#invalid.value = invalid; 99 | return obj; 100 | }, 101 | addAction: (title: Refable, icon: Component, onClick: () => void) => { 102 | this.#menu.addAction(title, icon, onClick); 103 | return obj; 104 | } 105 | }; 106 | return obj; 107 | } 108 | } 109 | 110 | export function DropDown(dropdown: Refable, selectedItem: Reference, label: Refable = "") { 111 | return new DropDownComponent(alwaysRef(dropdown), selectedItem, alwaysRef(label)).make(); 112 | } 113 | -------------------------------------------------------------------------------- /components/form/form.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component, HTMLComponent } from "../../core/mod.ts"; 2 | 3 | @asWebGenComponent("form") 4 | export class FormComponent extends HTMLComponent { 5 | #form = document.createElement("form"); 6 | constructor(components: Component) { 7 | super(); 8 | 9 | this.shadowRoot?.append(this.#form); 10 | this.#form.append(components.draw()); 11 | } 12 | } -------------------------------------------------------------------------------- /components/form/inlineInput.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, css, HTMLComponent, Reference, WriteSignal } from "../../core/mod.ts"; 2 | import { alwaysRef, asRef, Refable } from "../../core/state.ts"; 3 | 4 | @asWebGenComponent("inline-input") 5 | class InlineInputComponent extends HTMLComponent { 6 | input = document.createElement("input"); 7 | #focused = asRef(false); 8 | 9 | constructor(value: WriteSignal, placeholder: Reference) { 10 | super(); 11 | this.shadowRoot!.append(this.input); 12 | 13 | this.input.placeholder = placeholder.value; 14 | 15 | this.input.addEventListener("input", () => { 16 | value.value = this.input.value; 17 | }); 18 | 19 | this.shadowRoot!.adoptedStyleSheets.push(css` 20 | input { 21 | background-color: transparent; 22 | border: none; 23 | outline: none; 24 | color: inherit; 25 | font-size: inherit; 26 | width: 100%; 27 | } 28 | input::placeholder { 29 | color: inherit; 30 | } 31 | `); 32 | 33 | this.useListener(this.#focused, focused => { 34 | if (focused) { 35 | this.input.focus(); 36 | } 37 | }); 38 | 39 | this.useListener(value, value => { 40 | this.input.value = value; 41 | }); 42 | this.addEventListener("blur", () => { 43 | this.#focused.value = false; 44 | }); 45 | 46 | } 47 | 48 | override make() { 49 | const obj = { 50 | ...super.make(), 51 | focusedState: () => { 52 | return this.#focused; 53 | } 54 | }; 55 | return obj; 56 | } 57 | } 58 | 59 | export function InlineInput(value: WriteSignal, placeholder: Refable) { 60 | return new InlineInputComponent(value, alwaysRef(placeholder)).make(); 61 | } -------------------------------------------------------------------------------- /components/form/input.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asWebGenComponent, Box, Color, css, HTMLComponent, Label, Refable, WriteSignal } from "../../core/mod.ts"; 2 | import { asRef } from "../../core/state.ts"; 3 | 4 | @asWebGenComponent("input") 5 | class InputComponent extends HTMLComponent { 6 | #inputBg = new Color("var(--wg-input-background-color, var(--wg-primary))"); 7 | #input = document.createElement("input"); 8 | #disabled = asRef(false); 9 | #readOnly = asRef(false); 10 | #invalid = asRef(false); 11 | 12 | constructor(type: string, value: WriteSignal, label: Refable, valueChangeMode: "change" | "input" = "input") { 13 | super(); 14 | if (type === "text-area") { 15 | this.#input = document.createElement("textarea") as HTMLElement as HTMLInputElement; 16 | this.setAttribute("text-area", ""); 17 | } else { 18 | this.#input.type = type; 19 | } 20 | this.#input.classList.add("input"); 21 | this.shadowRoot!.append(Box(alwaysRef(label).map(placeholder => placeholder ? Label(placeholder) : [])).addClass("label").draw(), this.#input); 22 | 23 | this.useListener(alwaysRef(label), (text) => { 24 | if (text) { 25 | this.ariaLabel = text; 26 | } else { 27 | this.ariaLabel = null; 28 | } 29 | }); 30 | 31 | this.useListener(this.#disabled, (disabled) => { 32 | this.#input.disabled = disabled; 33 | if (disabled) { 34 | this.setAttribute("disabled", ""); 35 | } else { 36 | this.removeAttribute("disabled"); 37 | } 38 | }); 39 | 40 | this.useListener(this.#invalid, (invalid) => { 41 | if (invalid) { 42 | this.#input.setCustomValidity("Invalid"); 43 | this.setAttribute("invalid", ""); 44 | } else { 45 | this.#input.setCustomValidity(""); 46 | this.removeAttribute("invalid"); 47 | } 48 | }); 49 | 50 | this.useListener(this.#readOnly, (readOnly) => { 51 | this.#input.readOnly = readOnly; 52 | if (readOnly) { 53 | this.setAttribute("readonly", ""); 54 | } else { 55 | this.removeAttribute("readonly"); 56 | } 57 | }); 58 | 59 | this.useEventListener(this.#input, "input", () => { 60 | if (this.#input.value === "") { 61 | this.removeAttribute("has-value"); 62 | } else { 63 | this.setAttribute("has-value", ""); 64 | } 65 | }); 66 | 67 | this.useEventListener(this.#input, valueChangeMode, () => { 68 | value.value = this.#input.value || undefined; 69 | }); 70 | 71 | this.useListener(value, (newValue) => { 72 | this.#input.value = newValue ?? ""; 73 | if (newValue) { 74 | this.setAttribute("has-value", ""); 75 | } else { 76 | this.removeAttribute("has-value"); 77 | } 78 | }); 79 | 80 | this.shadowRoot!.adoptedStyleSheets.push(css` 81 | :host { 82 | display: grid; 83 | grid-template-columns: auto; 84 | grid-auto-columns: max-content; 85 | min-height: var(--wg-input-height, 50px); 86 | background-color: ${this.#inputBg.mix(Color.transparent, 95)}; 87 | color: ${this.#inputBg.toString()}; 88 | padding: var(--wg-button-padding, 0 10px); 89 | border-radius: var(--wg-input-border-radius, var(--wg-radius-tiny)); 90 | border-bottom: 1px solid; 91 | align-items: center; 92 | } 93 | .input { 94 | all: unset; 95 | font-size: var(--wg-input-font-size, 15px); 96 | z-index: 1; 97 | opacity: 0; 98 | min-width: 0px; 99 | } 100 | :host(:not([aria-label])) .input { 101 | opacity: unset; 102 | } 103 | textarea.input { 104 | height: 120px; 105 | margin-top: 12px; 106 | opacity: 1; 107 | } 108 | :host > * { 109 | grid-row: 1; 110 | grid-column: 1; 111 | transition: all 200ms ease; 112 | width: 100%; 113 | } 114 | .label { 115 | text-overflow: ellipsis; 116 | white-space: nowrap; 117 | overflow: hidden; 118 | display: block; 119 | } 120 | :host([has-value]) .label, 121 | :host(:focus-within) .label { 122 | font-size: var(--wg-label-font-size, 10px); 123 | font-weight: var(--wg-input-font-weight, bold); 124 | margin-bottom: 20px; 125 | } 126 | :host([text-area]) .label { 127 | margin-bottom: 110px; 128 | } 129 | :host([has-value][aria-label]) .input, 130 | :host([aria-label]) .input:focus-within { 131 | margin-top: 14px; 132 | opacity: 1; 133 | } 134 | 135 | :host([text-area][aria-label]) textarea.input { 136 | margin-top: 25px; 137 | } 138 | 139 | :host([readonly]:focus-within), 140 | :host([has-value]:focus-within) { 141 | background-color: ${this.#inputBg.mix(Color.transparent, 90)}; 142 | border-bottom: 2px solid; 143 | margin-bottom: -1px; 144 | } 145 | 146 | :host([invalid]) { 147 | background: var(--wg-button-invalid-color, hsl(0deg 40% 18%)); 148 | color: var(--wg-button-invalid-color, hsl(0deg 90% 70%)); 149 | } 150 | 151 | :host([disabled]) { 152 | background: var(--wg-button-disabled-color, hsl(0deg 0% 18%)); 153 | color: var(--wg-button-disabled-text-color, hsl(0deg 0% 50%)); 154 | border-bottom: 0px solid; 155 | margin-bottom: 1px; 156 | } 157 | `); 158 | } 159 | 160 | override make() { 161 | const obj = { 162 | ...super.make(), 163 | setDisabled: (disabled: Refable = true) => { 164 | this.useListener(alwaysRef(disabled), (disabled) => { 165 | this.#disabled.value = disabled; 166 | }); 167 | return obj; 168 | }, 169 | onFocus: (callback: () => void) => { 170 | this.useEventListener(this.#input, "focus", callback); 171 | return obj; 172 | }, 173 | setReadOnly: (readOnly: Refable = true) => { 174 | this.useListener(alwaysRef(readOnly), (readOnly) => { 175 | this.#readOnly.value = readOnly; 176 | }); 177 | return obj; 178 | }, 179 | setInvalid: (invalid: Refable = true) => { 180 | this.useListener(alwaysRef(invalid), (invalid) => { 181 | this.#invalid.value = invalid; 182 | }); 183 | return obj; 184 | } 185 | }; 186 | return obj; 187 | } 188 | } 189 | 190 | export function TextInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 191 | return new InputComponent("text", value, label, valueChangeMode).make(); 192 | } 193 | 194 | export function EmailInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 195 | return new InputComponent("email", value, label, valueChangeMode).make(); 196 | } 197 | 198 | export function PasswordInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 199 | return new InputComponent("password", value, label, valueChangeMode).make(); 200 | } 201 | 202 | export function DateInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 203 | return new InputComponent("date", value, label, valueChangeMode).make(); 204 | } 205 | 206 | export function DateTimeInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 207 | return new InputComponent("datetime-local", value, label, valueChangeMode).make(); 208 | } 209 | 210 | export function TimeInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 211 | return new InputComponent("time", value, label, valueChangeMode).make(); 212 | } 213 | 214 | export function TextAreaInput(value: WriteSignal, label: Refable = undefined, valueChangeMode: "change" | "input" = "input") { 215 | return new InputComponent("text-area", value, label, valueChangeMode).make(); 216 | } -------------------------------------------------------------------------------- /components/icons.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, css, HTMLComponent, lazy } from "../core/mod.ts"; 2 | import { alwaysRef, asRef, listen, Refable, Reference } from "../core/state.ts"; 3 | 4 | export const IconType = { 5 | bootstrap: await import("https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/bootstrap-icons.svg").then(x => 'default' in x ? x.default as string : undefined!), 6 | materialSymbol: lazy(() => import("https://cdn.jsdelivr.net/npm/material-symbols@0.23.0/rounded.css")), 7 | }; 8 | 9 | @asWebGenComponent("icon") 10 | export class IconComponent extends HTMLComponent { 11 | static weight = asRef(400); 12 | static grade = asRef(-25); 13 | static size = asRef(24); 14 | static fill = asRef<0 | 1>(0); 15 | 16 | constructor(type: keyof typeof IconType, icon: Reference) { 17 | super(); 18 | 19 | if (type === "bootstrap") { 20 | const data = IconType[ type ]; 21 | this.addWatch(() => listen(() => { 22 | this.shadowRoot!.innerHTML = ` 23 | 24 | 25 | 26 | `; 27 | })); 28 | } 29 | else { 30 | IconType[ type ](); 31 | 32 | this.shadowRoot?.adoptedStyleSheets.push(css` 33 | :host { 34 | font-family: "Material Symbols Rounded"; 35 | font-weight: normal; 36 | font-style: normal; 37 | font-size: 24px; 38 | line-height: 1; 39 | letter-spacing: normal; 40 | text-transform: none; 41 | display: inline-block; 42 | white-space: nowrap; 43 | word-wrap: normal; 44 | direction: ltr; 45 | -webkit-font-smoothing: antialiased; 46 | -moz-osx-font-smoothing: grayscale; 47 | text-rendering: optimizeLegibility; 48 | font-feature-settings: "liga"; 49 | } 50 | `); 51 | 52 | this.useListener(icon, value => { 53 | this.shadowRoot!.textContent = value; 54 | }); 55 | } 56 | 57 | const style = new CSSStyleSheet(); 58 | this.shadowRoot?.adoptedStyleSheets.push(style); 59 | this.addWatch(() => listen(() => { 60 | style.replaceSync(` 61 | :host { 62 | font-variation-settings: 63 | 'FILL' ${IconComponent.fill.value}, 64 | 'wght' ${IconComponent.weight.value}, 65 | 'GRAD' ${IconComponent.grade.value}, 66 | 'opsz' ${IconComponent.size.value} 67 | } 68 | `); 69 | })); 70 | } 71 | } 72 | 73 | export function MaterialIcon(icon: Refable) { 74 | return new IconComponent("materialSymbol", alwaysRef(icon)).make(); 75 | } 76 | 77 | export function BootstrapIcon(icon: Refable) { 78 | return new IconComponent("bootstrap", alwaysRef(icon)).make(); 79 | } -------------------------------------------------------------------------------- /components/image.ts: -------------------------------------------------------------------------------- 1 | import { asRef, asWebGenComponent, Box, css, HTMLComponent } from "../core/mod.ts"; 2 | import { Spinner } from "./spinner.ts"; 3 | 4 | @asWebGenComponent("image") 5 | export class ImageComponent extends HTMLComponent { 6 | loaded = asRef(false); 7 | constructor(source: string, alt: string) { 8 | super(); 9 | const image = document.createElement("img"); 10 | 11 | image.src = source; 12 | image.alt = alt; 13 | 14 | const loadEvent = () => { 15 | this.loaded.value = true; 16 | image.setAttribute("loaded", ""); 17 | }; 18 | 19 | image.addEventListener("load", loadEvent, { once: true }); 20 | this.useDisconnect(() => { 21 | image.removeEventListener("load", loadEvent); 22 | }); 23 | 24 | const spinner = Spinner().setCssStyle("placeSelf", "center"); 25 | this.shadowRoot!.append(Box( 26 | this.loaded.map(loaded => loaded ? [] : spinner), 27 | { draw: () => image } 28 | ).addClass("box").draw()); 29 | 30 | 31 | this.shadowRoot!.adoptedStyleSheets.push(css` 32 | :host { 33 | overflow: hidden; 34 | } 35 | .box { 36 | display: grid; 37 | } 38 | .box > * { 39 | grid-row: 1; 40 | grid-column: 1; 41 | } 42 | img { 43 | opacity: 0; 44 | width: 100%; 45 | } 46 | img[loaded] { 47 | opacity: 1; 48 | } 49 | `); 50 | 51 | } 52 | } 53 | 54 | export function Image(source: string, alt: string) { 55 | return new ImageComponent(source, alt).make(); 56 | } -------------------------------------------------------------------------------- /components/menu.ts: -------------------------------------------------------------------------------- 1 | import { asRef, asWebGenComponent, Box, Color, Component, css, HTMLComponent, Refable, Reference } from "../core/mod.ts"; 2 | import { alwaysRef } from "../core/state.ts"; 3 | import { InlineInput } from "./form/inlineInput.ts"; 4 | import { MaterialIcon } from "./icons.ts"; 5 | import { PrimaryButton, TextButton } from "./mod.ts"; 6 | 7 | export type SearchEngine = (search: Reference) => Reference; 8 | 9 | export function createSimpleSearchEngine(request: (search: string) => Promise): SearchEngine { 10 | const activeItems = asRef([]); 11 | const activeSearches: Promise[] = []; 12 | 13 | return (activeSearch) => { 14 | activeSearch.listen(async (search) => { 15 | const promise = request(search); 16 | activeSearches.push(promise); 17 | activeItems.value = []; 18 | await promise; 19 | if (activeSearches.at(-1) !== promise) return; 20 | activeItems.value = await promise; 21 | }); 22 | return activeItems; 23 | }; 24 | } 25 | 26 | @asWebGenComponent("menu") 27 | class MenuComponent extends HTMLComponent { 28 | #valueRender = asRef((value: string) => value); 29 | #showSearch: Reference; 30 | #searchLabel = asRef("Search"); 31 | #searchValue = asRef(""); 32 | #actions = asRef<{ title: Refable, icon: Component, onClick: () => void; }[]>([]); 33 | #searchInput = InlineInput(this.#searchValue, this.#searchLabel); 34 | #onItemClicked = asRef((_item: string) => { }); 35 | 36 | #searchEngine: Reference = asRef(((search) => { 37 | const items = this.items.value; 38 | const valueRender = this.#valueRender.value; 39 | return search.map(search => items.filter(item => valueRender(item).toLowerCase().includes(search.toLowerCase()))); 40 | })); 41 | 42 | constructor(private items: Reference) { 43 | super(); 44 | this.#showSearch = items.map(list => list.length > 15); 45 | 46 | const searchBox = Box( 47 | MaterialIcon("search").setTextSize("base"), 48 | this.#searchInput 49 | ) 50 | .setRadius("tiny") 51 | .addStyle(css` 52 | :host { 53 | display: grid; 54 | grid-template-columns: max-content auto; 55 | background: ${Color.reverseNeutral.mix(Color.primary, `5%`)}; 56 | height: 28px; 57 | align-items: center; 58 | font-size: 13.8px; 59 | padding: 0 8px; 60 | gap: 5px; 61 | } 62 | `); 63 | 64 | this.shadowRoot!.append( 65 | Box( 66 | this.#searchEngine.map(engine => { 67 | const activeItems = engine(this.#searchValue); 68 | 69 | return Box(activeItems.map(items => items.map((item) => Box( 70 | TextButton(this.#valueRender.value(item)) 71 | .addStyle(css` 72 | button { 73 | justify-content: start; 74 | } 75 | `) 76 | .addClass("item") 77 | .onClick(() => { 78 | this.#onItemClicked.value(item); 79 | }) 80 | )))) 81 | .addPrefix(Box( 82 | this.#showSearch.map(showSearch => showSearch ? searchBox : []) 83 | )) 84 | .addSuffix(Box( 85 | this.#actions.map(actions => actions.map(action => 86 | PrimaryButton(action.title) 87 | .addPrefix(action.icon) 88 | .onClick(action.onClick) 89 | .addStyle(css` 90 | button { 91 | justify-content: start; 92 | --wg-button-font-size: 13.8px; 93 | --wg-button-height: 28px; 94 | --wg-button-text-padding: 0 2px; 95 | padding: 0 8px; 96 | --wg-button-background-color: ${Color.reverseNeutral.mix(Color.primary, `5%`)}; 97 | --wg-button-text-color: var(--wg-neutral); 98 | } 99 | button>wg-icon { 100 | font-size: 19px; 101 | } 102 | `) 103 | )) 104 | )) 105 | .addClass("list") 106 | .setRadius("mid") 107 | ; 108 | }) 109 | ) 110 | .draw() 111 | ); 112 | 113 | 114 | this.shadowRoot!.adoptedStyleSheets.push(css` 115 | .list { 116 | max-height: 100%; 117 | display: grid; 118 | gap: 5px; 119 | background-color: ${Color.reverseNeutral.mix(Color.primary, `25%`)}; 120 | padding: 5px; 121 | --wg-button-padding: 0 5px; 122 | overflow: auto; 123 | box-shadow: var(--wg-shadow-4); 124 | } 125 | `); 126 | } 127 | 128 | override make() { 129 | const obj = { 130 | ...super.make(), 131 | setValueRenderer: (renderer: Refable<(value: string) => string>) => { 132 | this.useListener(alwaysRef(renderer), value => { 133 | this.#valueRender.value = value; 134 | }); 135 | return obj; 136 | }, 137 | setSearchEngine: (searchEngine: SearchEngine) => { 138 | this.#searchEngine.value = searchEngine; 139 | return obj; 140 | }, 141 | focusedState: () => { 142 | return this.#searchInput.focusedState(); 143 | }, 144 | onItemClick: (callback: (item: string) => void) => { 145 | this.#onItemClicked.value = callback; 146 | return obj; 147 | }, 148 | clearSearch: () => { 149 | this.#searchValue.value = ""; 150 | return obj; 151 | }, 152 | addAction: (title: Refable, icon: Component, onClick: () => void) => { 153 | this.#actions.value.push({ title, icon, onClick }); 154 | return obj; 155 | } 156 | }; 157 | return obj; 158 | } 159 | } 160 | 161 | 162 | export function Menu(items: Refable) { 163 | return new MenuComponent(alwaysRef(items)).make(); 164 | } -------------------------------------------------------------------------------- /components/misc/mediaQueryRef.ts: -------------------------------------------------------------------------------- 1 | import { asRef } from "../../core/mod.ts"; 2 | 3 | export function mediaQueryRef(matchString: string) { 4 | const query = matchMedia(matchString); 5 | const pointer = asRef(query.matches); 6 | query.addEventListener("change", ({ matches }) => pointer.setValue(matches), { passive: true }); 7 | return pointer; 8 | } -------------------------------------------------------------------------------- /components/misc/mobileQuery.ts: -------------------------------------------------------------------------------- 1 | import { mediaQueryRef } from "./mediaQueryRef.ts"; 2 | 3 | export const isMobile = mediaQueryRef("(max-width: 750px)"); -------------------------------------------------------------------------------- /components/misc/themeQuery.ts: -------------------------------------------------------------------------------- 1 | import { mediaQueryRef } from "./mediaQueryRef.ts"; 2 | 3 | export const isDarkModePreferred = mediaQueryRef("(prefers-color-scheme: dark)"); -------------------------------------------------------------------------------- /components/mod.ts: -------------------------------------------------------------------------------- 1 | export * from './appendBody.ts'; 2 | export * from './entry.ts'; 3 | export * from './form/button.ts'; 4 | export * from './form/checkbox.ts'; 5 | export * from './form/dropdown.ts'; 6 | export * from './form/inlineInput.ts'; 7 | export * from './form/input.ts'; 8 | export * from "./icons.ts"; 9 | export * from "./image.ts"; 10 | export * from './menu.ts'; 11 | export * from "./misc/mediaQueryRef.ts"; 12 | export * from "./misc/mobileQuery.ts"; 13 | export * from "./misc/themeQuery.ts"; 14 | export * from './spinner.ts'; 15 | export * from "./stacking/dialogContainer.ts"; 16 | export * from "./stacking/sheetHeader.ts"; 17 | export * from "./stacking/sheets.ts"; 18 | export * from "./table.ts"; 19 | export * from './theme.ts'; 20 | -------------------------------------------------------------------------------- /components/spinner.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, HTMLComponent } from "../core/mod.ts"; 2 | 3 | @asWebGenComponent("spinner") 4 | export class SpinnerComponent extends HTMLComponent { 5 | constructor() { 6 | super(); 7 | this.shadowRoot!.innerHTML = ''; 8 | } 9 | } 10 | 11 | export function Spinner() { 12 | return new SpinnerComponent().make(); 13 | } -------------------------------------------------------------------------------- /components/stacking/dialogContainer.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, css, HTMLComponent, WriteSignal, type Component } from "../../core/mod.ts"; 2 | import { asRef } from "../../core/state.ts"; 3 | 4 | @asWebGenComponent("dialog-container") 5 | export class DialogContainerComponent extends HTMLComponent { 6 | #dialog = document.createElement("dialog"); 7 | #shouldCloseItself = asRef((): boolean => true); 8 | constructor(isOpen: WriteSignal, component: Component) { 9 | super(); 10 | this.#dialog.append(component.draw()); 11 | this.shadowRoot!.append(this.#dialog); 12 | this.shadowRoot?.adoptedStyleSheets.push(css` 13 | :host { 14 | display: contents; 15 | } 16 | dialog { 17 | border: none; 18 | padding: unset; 19 | max-width: 100%; 20 | max-height: 100%; 21 | background: transparent; 22 | outline: none; 23 | box-sizing: border-box; 24 | } 25 | :host([full-screen]) dialog { 26 | width: unset; 27 | height: unset; 28 | } 29 | :host([no-overflow]) dialog { 30 | overflow: unset; 31 | } 32 | dialog::backdrop { 33 | background: rgba(0, 0, 0, 30%); 34 | } 35 | `); 36 | isOpen.listen((open) => { 37 | if (open === this.#dialog.open) return; 38 | if (open) { 39 | this.#dialog.showModal(); 40 | } else { 41 | this.#dialog.close(); 42 | } 43 | }); 44 | this.#dialog.addEventListener("click", (event) => { 45 | if (!this.#shouldCloseItself.value()) { 46 | return; 47 | } 48 | if (event.target == this.#dialog) { 49 | isOpen.value = false; 50 | } 51 | }); 52 | this.#dialog.addEventListener("close", (event) => { 53 | if (!this.#shouldCloseItself.value()) { 54 | event.preventDefault(); 55 | return; 56 | } 57 | isOpen.value = false; 58 | }); 59 | } 60 | 61 | override make() { 62 | const obj = { 63 | ...super.make(), 64 | setShouldCloseItself: (callback: () => boolean) => { 65 | this.#shouldCloseItself.value = () => callback(); 66 | return obj; 67 | } 68 | }; 69 | return obj; 70 | } 71 | } 72 | 73 | export function DialogContainer(isOpen: WriteSignal, component: Component) { 74 | return new DialogContainerComponent(isOpen, component).make(); 75 | } 76 | -------------------------------------------------------------------------------- /components/stacking/sheetHeader.ts: -------------------------------------------------------------------------------- 1 | import { css, Grid, Label, type Refable } from "../../core/mod.ts"; 2 | import { MaterialIcon, SecondaryButton } from "../mod.ts"; 3 | import type { Sheets } from "./sheets.ts"; 4 | 5 | export function SheetHeader(label: Refable, sheets: ReturnType) { 6 | return Grid( 7 | Label(label) 8 | .setTextSize("3xl") 9 | .setFontWeight("bold") 10 | .setMargin("30px 30px 0 0"), 11 | SecondaryButton("") 12 | .addPrefix(MaterialIcon("close")) 13 | .onClick(() => sheets.removeOne()) 14 | .addStyle(css` 15 | :host { 16 | --wg-button-text-padding: 0; 17 | --wg-button-padding: 0 6px; 18 | } 19 | `) 20 | ) 21 | .setAutoFlow("column") 22 | .setJustifyContent("space-between"); 23 | } -------------------------------------------------------------------------------- /components/stacking/sheets.ts: -------------------------------------------------------------------------------- 1 | import { asRef, asWebGenComponent, Box, Color, css, HTMLComponent, type Component } from "../../core/mod.ts"; 2 | 3 | @asWebGenComponent("sheets") 4 | export class SheetsComponent extends HTMLComponent { 5 | #count = asRef(0); 6 | constructor() { 7 | super(); 8 | this.shadowRoot?.adoptedStyleSheets.push(css` 9 | :host { 10 | display: grid; 11 | grid-template: 1fr / 1fr; 12 | max-width: 100dvw; 13 | max-height: 100dvh; 14 | padding-top: var(--wg-sheets-padding-top, 20px); 15 | box-sizing: border-box; 16 | } 17 | .sheet { 18 | grid-area: 1 / 1; 19 | background-color: ${Color.reverseNeutral.mix(Color.primary, `calc(3% * calc(4 - calc(var(--sheet-depth) * 2)))`)}; 20 | padding: var(--wg-sheet-padding, 15px); 21 | border-radius: var(--wg-padding-radius, var(--wg-radius-large)); 22 | overflow: auto; 23 | margin: calc(0px - var(--sheet-depth) * 8px) calc(var(--sheet-depth) * 8px) 0; 24 | transition: background-color 250ms ease, margin 250ms ease; 25 | 26 | z-index: 1; 27 | translate: 0; 28 | animation: fadeIn 250ms ease; 29 | } 30 | @keyframes fadeIn { 31 | from { 32 | translate: 0 30px; 33 | } 34 | } 35 | `); 36 | } 37 | 38 | private calculateDepth() { 39 | for (const [ index, element ] of Array.from(this.shadowRoot!.children).toReversed().entries()) { 40 | (element as HTMLElement).style.opacity = index > 2 ? "0" : ""; 41 | (element as HTMLElement).style.setProperty("--sheet-depth", `${index}`); 42 | } 43 | 44 | } 45 | 46 | override make() { 47 | const obj = { 48 | ...super.make(), 49 | addSheet: (sheet: Component) => { 50 | this.shadowRoot!.append(Box(sheet).addClass("sheet").draw()); 51 | this.#count.value++; 52 | this.calculateDepth(); 53 | return obj; 54 | }, 55 | removeOne: () => { 56 | if (this.#count.value === 0) return obj; 57 | Array.from(this.shadowRoot!.children).at(-1)!.remove(); 58 | this.#count.value--; 59 | this.calculateDepth(); 60 | return obj; 61 | }, 62 | removeAll: () => { 63 | this.shadowRoot!.innerHTML = ""; 64 | this.#count.value = 0; 65 | this.calculateDepth(); 66 | return obj; 67 | }, 68 | visible: () => { 69 | const open = asRef(this.#count.value > 0); 70 | this.useListener(open, value => { 71 | if (!value) { 72 | obj.removeAll(); 73 | } 74 | }); 75 | this.useListener(this.#count.map((count) => count > 0), value => { 76 | open.value = value; 77 | }); 78 | return open; 79 | } 80 | }; 81 | return obj; 82 | } 83 | } 84 | 85 | export function Sheets() { 86 | return new SheetsComponent().make(); 87 | } 88 | -------------------------------------------------------------------------------- /components/styles.ts: -------------------------------------------------------------------------------- 1 | import { css } from "../core/mod.ts"; 2 | 3 | export const wgStyleValues = css` 4 | :root { 5 | --wg-gap: 10px; 6 | 7 | --wg-radius-tiny: 0.3rem; 8 | --wg-radius-mid: 0.5rem; 9 | --wg-radius-large: 0.8rem; 10 | --wg-radius-extra: 1.8rem; 11 | --wg-radius-complete: 100rem; 12 | --wg-radius-none: 0; 13 | 14 | --wg-shadow-0: 0 1px 2px rgba(0, 0, 0, 0), 0 1px 3px 1px rgba(0, 0, 0, 0); 15 | --wg-shadow-1: 0 1px 2px rgba(0, 0, 0, 0.3), 0 1px 3px 1px rgba(0, 0, 0, 0.15); 16 | --wg-shadow-2: 0 1px 2px rgba(0, 0, 0, 0.3), 0 2px 6px 2px rgba(0, 0, 0, 0.15); 17 | --wg-shadow-3: 0 4px 8px 3px rgba(0, 0, 0, 0.15), 0 1px 3px rgba(0, 0, 0, 0.3); 18 | --wg-shadow-4: 0 6px 10px 4px rgba(0, 0, 0, 0.15), 0 2px 3px rgba(0, 0, 0, 0.3); 19 | --wg-shadow-5: 0 8px 12px 6px rgba(0, 0, 0, 0.15), 0 4px 4px rgba(0, 0, 0, 0.3); 20 | 21 | --wg-fontsize-xs: 0.75rem; 22 | --wg-lineheight-xs: 1rem; 23 | --wg-fontsize-sm: 0.875rem; 24 | --wg-lineheight-sm: 1.25rem; 25 | --wg-fontsize-base: 1rem; 26 | --wg-lineheight-base: 1.5rem; 27 | --wg-fontsize-lg: 1.125rem; 28 | --wg-lineheight-lg: 1.75rem; 29 | --wg-fontsize-xl: 1.25rem; 30 | --wg-lineheight-xl: 1.75rem; 31 | --wg-fontsize-2xl: 1.5rem; 32 | --wg-lineheight-2xl: 2rem; 33 | --wg-fontsize-3xl: 1.875rem; 34 | --wg-lineheight-3xl: 2.25rem; 35 | --wg-fontsize-4xl: 2.25rem; 36 | --wg-lineheight-4xl: 2.5rem; 37 | --wg-fontsize-5xl: 3rem; 38 | --wg-lineheight-5xl: 1; 39 | --wg-fontsize-6xl: 3.75rem; 40 | --wg-lineheight-6xl: 1; 41 | --wg-fontsize-7xl: 4.5rem; 42 | --wg-lineheight-7xl: 1; 43 | --wg-fontsize-8xl: 6rem; 44 | --wg-lineheight-8xl: 1; 45 | --wg-fontsize-9xl: 8rem; 46 | --wg-lineheight-9xl: 1; 47 | 48 | --wg-fontweight-thin: 100; 49 | --wg-fontweight-extralight: 200; 50 | --wg-fontweight-light: 300; 51 | --wg-fontweight-normal: 400; 52 | --wg-fontweight-medium: 500; 53 | --wg-fontweight-semibold: 600; 54 | --wg-fontweight-bold: 700; 55 | --wg-fontweight-extrabold: 800; 56 | --wg-fontweight-black: 900; 57 | } 58 | `; -------------------------------------------------------------------------------- /components/table.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asRef, asWebGenComponent, Color, css, Grid, HTMLComponent, Label, WriteSignal, type Component, type Reference } from "../core/mod.ts"; 2 | import { Checkbox, type CheckboxValue } from "./form/checkbox.ts"; 3 | 4 | 5 | export type TableDefinition = { 6 | [ K in keyof T[ number ] ]?: { titleRenderer?: () => Component, cellRenderer?: (data: Readonly, row: Readonly) => Component, columnWidth?: string; } 7 | }; 8 | 9 | @asWebGenComponent("table") 10 | export class TableComponent extends HTMLComponent { 11 | #hoveringActive = asRef(false); 12 | #rowClickActive = asRef<(() => void) | undefined>(undefined); 13 | #selectionActive = asRef<(() => void) | undefined>(undefined); 14 | #selectedIndexes = asRef([]); 15 | #headerSelection = asRef(false); 16 | #inputBg = new Color("var(--wg-table-background-color, var(--wg-primary))"); 17 | #currentlyHoveredRow = asRef(undefined); 18 | #innerScroll = asRef(false); 19 | 20 | constructor(data: Reference, typeDefRef: Reference>) { 21 | super(); 22 | 23 | const items = asRef([]); 24 | const columSizes = asRef(""); 25 | 26 | this.addListen(() => { 27 | const columns = Array.from(new Set(data.value.flatMap(item => Object.keys(item) as (keyof T[ number ])[])).values()); 28 | const typeDef = typeDefRef.value; 29 | columSizes.value = columns.map(column => typeDef[ column ]?.columnWidth ?? "max-content").join(" "); 30 | items.value = [ 31 | ...columns.map((column, index) => 32 | Grid( 33 | alwaysRef(index === 0 && this.#selectionActive.value ? [ 34 | Checkbox(this.#headerSelection) 35 | .onClick((event: Event) => { 36 | event.stopPropagation(); 37 | if (this.#headerSelection.value === false) { 38 | this.#headerSelection.value = true; 39 | this.#selectedIndexes.value = data.value.map((_, index) => index); 40 | } else { 41 | this.#headerSelection.value = false; 42 | this.#selectedIndexes.value = []; 43 | } 44 | this.#selectionActive.value?.(); 45 | }) 46 | ] : []), 47 | typeDef[ column ]?.titleRenderer?.() ?? Label(column.toString()) 48 | ) 49 | .setGap(this.#selectionActive.value && index === 0 ? "10px" : "8px") 50 | .setAutoFlow("column") 51 | .setJustifyContent("start") 52 | .setHeight("35px") 53 | .setAlignContent("center") 54 | .addStyle(css` 55 | :host { 56 | position: sticky; 57 | top: ${this.#innerScroll.value ? "0" : "10px"}; 58 | background-color: ${this.#inputBg.mix(Color.transparent, 85)}; 59 | padding: 0 ${this.#selectionActive.value && index === 0 ? "6px" : "14px"}; 60 | border-radius: ${index === 0 ? "9px 0 0 9px" : index === columns.length - 1 ? "0 9px 9px 0" : "0"}; 61 | box-sizing: border-box; 62 | backdrop-filter: blur(10px); 63 | z-index: 1; 64 | } 65 | @supports (text-box: trim-both cap alphabetic) { 66 | :host { 67 | line-height: unset; 68 | text-box: trim-both cap alphabetic; 69 | } 70 | } 71 | `) 72 | ), 73 | ...data.value.flatMap((item, rowIndex) => columns.flatMap((column, columnIndex) => 74 | Grid( 75 | alwaysRef(columnIndex === 0 && this.#selectionActive.value ? [ 76 | Checkbox(this.#selectedIndexes.map(indexes => indexes.includes(rowIndex) as CheckboxValue) as WriteSignal) 77 | .onClick((event) => { 78 | event.stopPropagation(); 79 | if (this.#selectedIndexes.value.includes(rowIndex)) { 80 | this.#selectedIndexes.value = this.#selectedIndexes.value.filter(i => i !== rowIndex); 81 | } else { 82 | this.#selectedIndexes.value = [ ...this.#selectedIndexes.value, rowIndex ]; 83 | } 84 | if (this.#selectedIndexes.value.length === 0) { 85 | this.#headerSelection.value = false; 86 | } else if (this.#selectedIndexes.value.length !== data.value.length) { 87 | this.#headerSelection.value = "intermediate"; 88 | } else { 89 | this.#headerSelection.value = true; 90 | } 91 | this.#selectionActive.value?.(); 92 | }) 93 | ] : []), 94 | typeDef[ column ]?.cellRenderer?.(item[ column as keyof object ], item as T) ?? Label(item[ column as keyof object ]) 95 | ) 96 | .setGap(this.#selectionActive.value && columnIndex === 0 ? "10px" : "8px") 97 | .setAutoFlow("column") 98 | .setJustifyContent("start") 99 | .addClass("row") 100 | .setAttribute("data-row-index", rowIndex.toString()) 101 | .setAttribute("hover", this.#currentlyHoveredRow.map(hoverIndex => hoverIndex === rowIndex ? "" : undefined)) 102 | .setAttribute("clickable", this.#rowClickActive.map((active) => active ? "" : undefined)) 103 | .setHeight("35px") 104 | .setAlignContent("center") 105 | .addStyle(css` 106 | :host { 107 | padding: 0 ${this.#selectionActive.value && columnIndex === 0 ? "6px" : "14px"}; 108 | border-radius: ${columnIndex === 0 ? "9px 0 0 9px" : columnIndex === columns.length - 1 ? "0 9px 9px 0" : "0"}; 109 | box-sizing: border-box; 110 | ${rowIndex % 2 === 1 ? `background-color: ${this.#inputBg.mix(Color.transparent, 95)}` : ""}; 111 | transition: translate 250ms ease; 112 | min-width: max-content; 113 | } 114 | :host([hover]) { 115 | background-color: ${this.#inputBg.mix(Color.transparent, 90)}; 116 | } 117 | :host([clickable]) { 118 | cursor: pointer; 119 | } 120 | :host([clickable][hover]) { 121 | translate: 0px -2px; 122 | backdrop-filter: blur(50px); 123 | } 124 | `) 125 | ) 126 | ) 127 | ]; 128 | }); 129 | 130 | const table = Grid( 131 | items 132 | ) 133 | .setTemplateColumns(columSizes) 134 | .draw(); 135 | 136 | this.shadowRoot?.adoptedStyleSheets.push(css` 137 | :host([inner-scroll]) { 138 | overflow: auto; 139 | } 140 | `); 141 | 142 | this.make().setAttribute("inner-scroll", this.#innerScroll.map(inner => inner ? "" : undefined)); 143 | 144 | // mouse move 145 | this.useEventListener(table, "mousemove", (event) => { 146 | if (this.#hoveringActive.value === false) return; 147 | const row = event.composedPath().find(element => element instanceof HTMLElement && element.classList.contains("row")); 148 | if (!row || !(row instanceof HTMLElement)) { 149 | this.#currentlyHoveredRow.value = undefined; 150 | return; 151 | }; 152 | this.#currentlyHoveredRow.value = Number(row.getAttribute("data-row-index")); 153 | }); 154 | 155 | this.useEventListener(table, "mouseleave", () => { 156 | this.#currentlyHoveredRow.value = undefined; 157 | }); 158 | 159 | this.useEventListener(table, "click", () => { 160 | if (this.#currentlyHoveredRow.value === undefined) return; 161 | this.#rowClickActive.value?.(); 162 | }); 163 | 164 | this.shadowRoot!.append(table); 165 | } 166 | 167 | override make() { 168 | const obj = { 169 | ...super.make(), 170 | setHoveringActive: () => { 171 | this.#hoveringActive.value = true; 172 | return obj; 173 | }, 174 | onRowClick: (action: (rowId: number) => void) => { 175 | this.#hoveringActive.value = true; 176 | this.#rowClickActive.value = () => action(this.#currentlyHoveredRow.value!); 177 | return obj; 178 | }, 179 | makeScrollable: () => { 180 | this.#innerScroll.value = true; 181 | return obj; 182 | }, 183 | onSelection: (action: (selectedIndexes: number[]) => void) => { 184 | this.#selectionActive.value = () => { 185 | action(this.#selectedIndexes.value); 186 | }; 187 | return obj; 188 | }, 189 | }; 190 | return obj; 191 | } 192 | } 193 | 194 | export function Table(data: Reference, typeDefRef: Reference> = alwaysRef({})) { 195 | return new TableComponent(data, typeDefRef).make(); 196 | } -------------------------------------------------------------------------------- /components/theme.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asRef, asWebGenComponent, BoxComponent, Color, Component, listen, Refable, Reference } from "../core/mod.ts"; 2 | import { isDarkModePreferred } from "./misc/themeQuery.ts"; 3 | import { wgStyleValues } from "./styles.ts"; 4 | 5 | export enum ThemeMode { 6 | Auto = "auto", 7 | Light = "light", 8 | Dark = "dark" 9 | } 10 | 11 | @asWebGenComponent("theme") 12 | export class WebGenThemeComponent extends BoxComponent { 13 | #themeMode = asRef(ThemeMode.Auto); 14 | #primaryColor = asRef(undefined); 15 | #fontFamily = asRef("Red Hat Display,Roboto,system-ui,sans-serif"); 16 | 17 | constructor(component: Reference | Component, components: Component[]) { 18 | super(component, components); 19 | const meta = document.createElement("meta"); 20 | meta.name = "color-scheme"; 21 | document.adoptedStyleSheets.push(wgStyleValues); 22 | this.useListener(this.#themeMode, (current) => { 23 | if (current === ThemeMode.Auto) { 24 | meta.content = "dark light"; 25 | } 26 | else { 27 | meta.content = current; 28 | } 29 | }); 30 | document.head.append(meta); 31 | 32 | const font = document.createElement("style"); 33 | 34 | this.useListener(this.#fontFamily, (current) => { 35 | font.textContent = `body { 36 | font-family: ${current}; 37 | margin: 0; 38 | }`; 39 | }); 40 | 41 | document.head.append(font); 42 | const colors = document.createElement("style"); 43 | 44 | this.addWatch(() => { 45 | return listen(() => { 46 | const theme = this.#themeMode.value === ThemeMode.Auto ? (isDarkModePreferred.value ? ThemeMode.Dark : ThemeMode.Light) : this.#themeMode.value; 47 | document.querySelector("html")!.setAttribute("data-theme", theme); 48 | colors.textContent = ` 49 | html { 50 | --wg-primary: var(--wg-override-primary, oklch(83.53% 0.207 169.23)); 51 | --wg-primary-text: var(--wg-override-primary-text, var(--wg-black)); 52 | --wg-black: var(--wg-override-black, color-mix(in oklab, hsl(0deg 0% 10%), var(--wg-primary) 3%)); 53 | --wg-white: var(--wg-override-white, color-mix(in oklab, hsl(0deg 0% 90%), var(--wg-primary) 10%)); 54 | 55 | --wg-neutral: var(--wg-override-neutral, ${theme === ThemeMode.Dark ? "var(--wg-white)" : "var(--wg-black)"}); 56 | --wg-reverse-neutral: var(--wg-override-reverse-neutral, ${theme === ThemeMode.Dark ? "var(--wg-black)" : "var(--wg-white)"}); 57 | } 58 | [data-theme="light"] body 59 | { 60 | --wg-primary: color-mix(in oklab, var(--wg-override-primary, oklch(83.53% 0.207 169.23)), var(--wg-black) 50%); 61 | --wg-primary-text: color-mix(in oklab, var(--wg-override-primary-text, var(--wg-black)), var(--wg-white) 100%); 62 | } 63 | body { 64 | background-color: color-mix(in oklab, var(--wg-reverse-neutral), black 10%); 65 | color: var(--wg-neutral); 66 | } 67 | `; 68 | }); 69 | }); 70 | 71 | document.head.append(colors); 72 | 73 | const defaultColor = document.createElement("style"); 74 | this.useListener(this.#primaryColor, (current) => { 75 | if (current) { 76 | defaultColor.textContent = `:root { 77 | --wg-override-primary: ${current.toString()}; 78 | }`; 79 | } 80 | }); 81 | document.head.append(defaultColor); 82 | } 83 | 84 | override make() { 85 | const obj = { 86 | ...super.make(), 87 | useGrayscaleColor: () => { 88 | this.addWatch(() => listen(() => { 89 | const theme = this.#themeMode.value === ThemeMode.Auto ? (isDarkModePreferred.value ? ThemeMode.Dark : ThemeMode.Light) : this.#themeMode.value; 90 | 91 | this.#primaryColor.value = new Color(theme === ThemeMode.Dark ? "hsl(0, 0%, 90%)" : "hsl(0, 0%, 0%)"); 92 | })); 93 | return obj; 94 | }, 95 | setPrimaryColor: (color: Refable) => { 96 | this.useListener(alwaysRef(color), (current) => { 97 | this.#primaryColor.value = current; 98 | }); 99 | return obj; 100 | }, 101 | useAltLayout: () => { 102 | document.body.style.display = "grid"; 103 | document.body.style.height = "100dvh"; 104 | document.body.style.gridTemplate = "100% / 100%"; 105 | 106 | this.style.display = "grid"; 107 | this.style.width = "100%"; 108 | this.style.height = "100%"; 109 | this.style.gridTemplate = "100% / 100%"; 110 | return obj; 111 | } 112 | }; 113 | return obj; 114 | } 115 | } 116 | 117 | export function WebGenTheme(component: Reference | Component, ...components: Component[]) { 118 | return new WebGenThemeComponent(component, components).make(); 119 | } -------------------------------------------------------------------------------- /core/color.ts: -------------------------------------------------------------------------------- 1 | export class Color { 2 | constructor(private value: string) { } 3 | 4 | static primary = new Color("var(--wg-primary)"); 5 | static primaryText = new Color("var(--wg-primary-text)"); 6 | static black = new Color("var(--wg-black)"); 7 | static white = new Color("var(--wg-white)"); 8 | static neutral = new Color("var(--wg-neutral)"); 9 | static reverseNeutral = new Color("var(--wg-reverse-neutral)"); 10 | static transparent = new Color("transparent"); 11 | 12 | mix(color: Color, percentage: number | string) { 13 | return `color-mix(in oklab, ${this.value}, ${color.value} ${typeof percentage == "number" ? `${percentage}%` : percentage})`; 14 | } 15 | 16 | toString() { 17 | return this.value; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /core/components.ts: -------------------------------------------------------------------------------- 1 | import { alwaysRef, asRefArray, listen, ref, Refable, Reference } from "./state.ts"; 2 | import { FontWeight, Radius, TextSize } from "./types.ts"; 3 | 4 | // deno-lint-ignore prefer-const 5 | export let WEBGEN_PREFIX = "wg-"; 6 | 7 | export interface Component { 8 | draw: () => HTMLElement; 9 | } 10 | 11 | export function asWebGenComponent(value: string) { 12 | return (target: CustomElementConstructor) => { 13 | customElements.define(WEBGEN_PREFIX + value, target); 14 | }; 15 | } 16 | 17 | export type ContentDistribution = 'space-between' | 'space-around' | 'space-evenly' | 'stretch'; 18 | export type ContentPosition = 'center' | 'start' | 'end' | 'flex-start' | 'flex-end'; 19 | 20 | export class HTMLComponent extends HTMLElement { 21 | #listener = new Set<{ listen: () => void, unlisten: () => void; }>(); 22 | private cssClassList = asRefArray([]); 23 | 24 | addWatch(obj: () => { unlisten: () => void; }) { 25 | let unlisten = () => { }; 26 | this.#listener.add({ 27 | listen: () => { 28 | unlisten = obj().unlisten; 29 | }, 30 | unlisten: () => { 31 | unlisten(); 32 | } 33 | }); 34 | } 35 | protected useDisconnect(hook: () => void) { 36 | this.#listener.add({ 37 | listen: () => { }, 38 | unlisten: () => { 39 | hook(); 40 | } 41 | }); 42 | } 43 | protected useListener(ref: Reference, callback: (newValue: T, oldValue?: T) => void) { 44 | this.addWatch(() => ref.listen(callback)); 45 | } 46 | protected useEventListener(target: EventTarget, type: string, listener: EventListenerOrEventListenerObject, options?: AddEventListenerOptions) { 47 | this.#listener.add({ 48 | listen: () => target.addEventListener(type, listener, options), 49 | unlisten: () => target.removeEventListener(type, listener) 50 | }); 51 | } 52 | protected addListen(obj: (oldValue?: T) => T) { 53 | let oldValue: T | undefined = undefined; 54 | this.addWatch(() => listen(() => { 55 | oldValue = obj(oldValue); 56 | })); 57 | } 58 | protected connectedCallback() { 59 | this.#listener.forEach(listener => listener.listen()); 60 | } 61 | protected disconnectedCallback() { 62 | this.#listener.forEach(listener => listener.unlisten()); 63 | } 64 | constructor() { 65 | super(); 66 | this.useListener(this.cssClassList, (newList, oldList) => { 67 | const deletedItems = (oldList ?? []).filter(it => !newList.includes(it)); 68 | const addedItems = (newList ?? []).filter(it => !(oldList ?? []).includes(it)); 69 | this.classList.remove(...deletedItems); 70 | this.classList.add(...addedItems); 71 | }); 72 | this.attachShadow({ mode: "open" }); 73 | } 74 | make() { 75 | const obj = { 76 | draw: () => this, 77 | 78 | // Layout Additions 79 | addPrefix: (component: Component) => { this.shadowRoot!.prepend(component.draw()); return obj; }, 80 | addSuffix: (component: Component) => { this.shadowRoot!.append(component.draw()); return obj; }, 81 | 82 | // Common HTML 83 | addClass: (classToken: Refable, ...classes: string[]) => { 84 | this.classList.add(...classes); 85 | const token = alwaysRef(classToken); 86 | this.useListener(token, (newValue, oldValue) => { 87 | if (oldValue !== undefined && oldValue !== newValue) 88 | this.cssClassList.removeItem(oldValue); 89 | if (newValue !== undefined) 90 | this.cssClassList.addItem(newValue); 91 | }); 92 | return obj; 93 | }, 94 | addStyle: (style: CSSStyleSheet) => { 95 | this.shadowRoot!.adoptedStyleSheets.push(style); 96 | return obj; 97 | }, 98 | setAttribute: (key: string, value: Refable) => { 99 | this.useListener(alwaysRef(value), (newValue, oldValue) => { 100 | if (oldValue !== undefined) 101 | this.removeAttribute(key); 102 | if (newValue !== undefined) 103 | this.setAttribute(key, newValue); 104 | }); 105 | return obj; 106 | }, 107 | setCssStyle: (key: keyof Omit, value: Refable) => { 108 | this.useListener(alwaysRef(value), (newValue) => { 109 | // deno-lint-ignore no-explicit-any 110 | this.style[ key as any ] = newValue; 111 | }); 112 | return obj; 113 | }, 114 | setAnchorName: (name: Refable) => { 115 | obj.setAttribute("anchor", name); 116 | return obj; 117 | }, 118 | setViewTransitionName: (name: Refable) => { 119 | // deno-lint-ignore no-explicit-any 120 | obj.setCssStyle("viewTransitionName" as any, name); 121 | return obj; 122 | }, 123 | setPadding: (size: Refable) => { 124 | obj.setCssStyle("padding", size); 125 | return obj; 126 | }, 127 | setWidth: (size: Refable) => { 128 | obj.setCssStyle("width", size); 129 | return obj; 130 | }, 131 | setMinWidth: (size: Refable) => { 132 | obj.setCssStyle("minWidth", size); 133 | return obj; 134 | }, 135 | setMaxWidth: (size: Refable) => { 136 | obj.setCssStyle("maxWidth", size); 137 | return obj; 138 | }, 139 | setHeight: (size: Refable) => { 140 | obj.setCssStyle("height", size); 141 | return obj; 142 | }, 143 | setMinHeight: (size: Refable) => { 144 | obj.setCssStyle("minHeight", size); 145 | return obj; 146 | }, 147 | setMaxHeight: (size: Refable) => { 148 | obj.setCssStyle("maxHeight", size); 149 | return obj; 150 | }, 151 | setMargin: (size: Refable) => { 152 | obj.setCssStyle("margin", size); 153 | return obj; 154 | }, 155 | setId: (id: Refable) => { 156 | obj.setAttribute("id", id); 157 | return obj; 158 | }, 159 | setGrow: (value: Refable = 1) => { 160 | obj.setCssStyle("flexGrow", alwaysRef(value).map(it => it.toString())); 161 | return obj; 162 | }, 163 | setAlignItems: (value: Refable<"center" | "end" | "start" | "stretch">) => { 164 | obj.setCssStyle("alignItems", value); 165 | return obj; 166 | }, 167 | setAlignContent: (value: Refable<"normal" | ContentDistribution | ContentPosition>) => { 168 | obj.setCssStyle("alignContent", value); 169 | return obj; 170 | }, 171 | setAlignSelf: (value: Refable<"center" | "end" | "start" | "stretch">) => { 172 | obj.setCssStyle("alignSelf", value); 173 | return obj; 174 | }, 175 | setJustifyItems: (value: Refable<"center" | "end" | "start" | "stretch">) => { 176 | obj.setCssStyle("justifyItems", value); 177 | return obj; 178 | }, 179 | setJustifyContent: (value: Refable<"normal" | ContentDistribution | ContentPosition>) => { 180 | obj.setCssStyle("justifyContent", value); 181 | return obj; 182 | }, 183 | setJustifySelf: (value: Refable<"center" | "end" | "start" | "stretch">) => { 184 | obj.setCssStyle("justifySelf", value); 185 | return obj; 186 | }, 187 | setMixBlendMode: (value: Refable<'normal' | 'multiply' | 'screen' | 'overlay' | 'darken' | 'lighten' | 'color-dodge' | 'color-burn' | 'hard-light' | 'soft-light' | 'difference' | 'exclusion' | 'hue' | 'saturation' | 'color' | 'luminosity'>) => { 188 | obj.setCssStyle("mixBlendMode", value); 189 | return obj; 190 | }, 191 | setDirection: (value: Refable<"column" | "row" | "row-reverse" | "column-reverse">) => { 192 | obj.setCssStyle("flexDirection", value); 193 | return obj; 194 | }, 195 | 196 | // Specialized 197 | setShadow: (value: Refable<'0' | '1' | '2' | '3' | '4' | '5'>) => { 198 | obj.setCssStyle("boxShadow", ref`var(--wg-shadow-${value})`); 199 | return obj; 200 | }, 201 | setTextSize: (value: Refable) => { 202 | obj.setCssStyle("fontSize", ref`var(--wg-fontsize-${value})`); 203 | obj.setCssStyle("lineHeight", ref`var(--wg-lineheight-${value})`); 204 | return obj; 205 | }, 206 | setFontWeight: (value: Refable) => { 207 | obj.setCssStyle("fontWeight", ref`var(--wg-fontweight-${value})`); 208 | return obj; 209 | }, 210 | setRadius: (value: Refable) => { 211 | obj.setCssStyle("borderRadius", ref`var(--wg-radius-${value})`); 212 | return obj; 213 | }, 214 | 215 | // Events 216 | onClick: (action: (event: Event) => void) => { 217 | this.useEventListener(this, "click", action); 218 | return obj; 219 | }, 220 | onRightClick: (action: () => void) => { 221 | this.useEventListener(this, "contextmenu", action); 222 | return obj; 223 | } 224 | }; 225 | return obj; 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /core/cssTemplate.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Creates a CSSStyleSheet from a tagged templates 3 | * 4 | * To a WebGen Component: 5 | * 6 | * ```js 7 | * .addStyle(css``); 8 | * ``` 9 | * 10 | * Or add styling directly to the html: 11 | * 12 | * ```js 13 | * document.adoptedStyleSheets.push(css` 14 | * .footer { 15 | * gap: 0.5rem; 16 | * } 17 | *`); 18 | * ``` 19 | */ 20 | export function css(data: TemplateStringsArray, ...expr: string[]) { 21 | const merge = data.map((x, i) => x + (expr[ i ] || '')); 22 | 23 | const style = new CSSStyleSheet(); 24 | style.replaceSync(merge.join("")); 25 | return style; 26 | } -------------------------------------------------------------------------------- /core/layout/async.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component, HTMLComponent } from "../components.ts"; 2 | import { Slot } from "./slot.ts"; 3 | 4 | @asWebGenComponent("async") 5 | export class AsyncComponent extends HTMLComponent { 6 | 7 | constructor(source: Promise, fallback: Component) { 8 | super(); 9 | this.shadowRoot?.append(Slot().draw()); 10 | this.append(fallback.draw()); 11 | source.then(component => { 12 | this.replaceChildren(component.draw()); 13 | }); 14 | source.catch(component => { 15 | this.replaceChildren(component.draw()); 16 | }); 17 | } 18 | } 19 | 20 | export function Async(source: Promise, fallback: Component) { 21 | return new AsyncComponent(source, fallback).make(); 22 | } -------------------------------------------------------------------------------- /core/layout/box.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component, HTMLComponent } from "../components.ts"; 2 | import { alwaysRef, Reference } from "../state.ts"; 3 | 4 | @asWebGenComponent("box") 5 | export class BoxComponent extends HTMLComponent { 6 | constructor(component: Reference | Component, components: Component[]) { 7 | super(); 8 | const dynamicElement = document.createElement("slot"); 9 | dynamicElement.name = "dynamic"; 10 | const staticElements = document.createElement("slot"); 11 | staticElements.name = "static"; 12 | this.shadowRoot!.append(dynamicElement, staticElements); 13 | 14 | this.addListen(() => { 15 | const current = alwaysRef(component).value; 16 | const alwaysList = Array.isArray(current) ? current : [ current ]; 17 | 18 | this.replaceChildren( 19 | ...alwaysList 20 | .map(component => component.draw()) 21 | .map(element => { 22 | element.slot = "dynamic"; 23 | return element; 24 | }), 25 | ...components 26 | .map(component => component.draw()) 27 | .map(element => { 28 | element.slot = "static"; 29 | return element; 30 | }) 31 | ); 32 | }); 33 | } 34 | } 35 | 36 | export function Box(component: Reference | Component, ...components: Component[]) { 37 | return new BoxComponent(component, components).make(); 38 | } 39 | -------------------------------------------------------------------------------- /core/layout/content.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component, HTMLComponent } from "../components.ts"; 2 | import { alwaysRef, Reference } from "../state.ts"; 3 | 4 | @asWebGenComponent("full-width-section") 5 | class FullWidthSectionComponent extends HTMLComponent { 6 | constructor( 7 | components: Component[] 8 | ) { 9 | super(); 10 | 11 | this.style.display = "grid"; 12 | this.style.gridTemplateColumns = "inherit"; 13 | this.style.gridColumn = "full-width"; 14 | 15 | for (const iterator of components) { 16 | const item = iterator.draw(); 17 | item.style.gridColumn = "full-width"; 18 | this.append(item); 19 | } 20 | } 21 | } 22 | export const FullWidthSection = (...elements: Component[]) => new FullWidthSectionComponent(elements).make(); 23 | 24 | @asWebGenComponent("content") 25 | export class ContentComponent extends HTMLComponent { 26 | constructor(component: Reference | Component, components: Component[]) { 27 | super(); 28 | this.style.display = "grid"; 29 | 30 | this.style.setProperty("--content-padding", "16px"); 31 | this.style.gridTemplateColumns = [ 32 | "[full-width-start]", 33 | "minmax(var(--content-padding), 1fr)", 34 | "[content-start]", 35 | "min(100% - (var(--content-padding) * 2), var(--content-max-width, 900px))", 36 | "[content-end]", 37 | "minmax(var(--content-padding), 1fr)", 38 | "[full-width-end]", 39 | ].join(" "); 40 | this.style.alignContent = "start"; 41 | 42 | this.useListener(alwaysRef(component), (current, oldValue) => { 43 | if (oldValue) { 44 | for (const iterator of Array.isArray(oldValue) ? oldValue : [ oldValue ]) { 45 | const item = iterator.draw(); 46 | item.remove(); 47 | } 48 | } 49 | for (const iterator of Array.isArray(current) ? current : [ current ]) { 50 | const item = iterator.draw(); 51 | if (item instanceof FullWidthSectionComponent) { 52 | this.shadowRoot!.prepend(...Array.from(item.children)); 53 | } else { 54 | item.style.gridColumn = "content"; 55 | this.shadowRoot!.prepend(item); 56 | } 57 | } 58 | }); 59 | 60 | for (const iterator of components) { 61 | const item = iterator.draw(); 62 | if (item instanceof FullWidthSectionComponent) { 63 | this.shadowRoot!.append(...Array.from(item.children)); 64 | } else { 65 | item.style.gridColumn = "content"; 66 | this.shadowRoot!.append(item); 67 | } 68 | } 69 | } 70 | 71 | override make() { 72 | const obj = { 73 | ...super.make(), 74 | setContentMaxWidth: (size: string) => { 75 | this.style.setProperty("--content-max-width", size); 76 | return obj; 77 | }, 78 | setContentPadding: (size: string) => { 79 | this.style.setProperty("--content-padding", size); 80 | return obj; 81 | }, 82 | fillHeight: () => { 83 | this.style.marginBlockEnd = "auto"; 84 | return obj; 85 | } 86 | }; 87 | return obj; 88 | } 89 | } 90 | 91 | export const Content = (component: Reference | Component, ...components: Component[]) => new ContentComponent(component, components).make(); 92 | -------------------------------------------------------------------------------- /core/layout/empty.ts: -------------------------------------------------------------------------------- 1 | import { asRef } from "../state.ts"; 2 | import { Box } from "./box.ts"; 3 | 4 | export function Empty() { 5 | return Box(asRef([])); 6 | } -------------------------------------------------------------------------------- /core/layout/grid.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component } from "../components.ts"; 2 | import { alwaysRef, ref, Refable, Reference } from "../state.ts"; 3 | import { BoxComponent } from "./box.ts"; 4 | 5 | @asWebGenComponent("grid") 6 | export class GridComponent extends BoxComponent { 7 | constructor(component: Reference | Component, components: Component[]) { 8 | super(component, components); 9 | this.style.display = "grid"; 10 | } 11 | 12 | override make() { 13 | const obj = { 14 | ...super.make(), 15 | 16 | setGap(gap: Refable = "var(--wg-gap)") { 17 | obj.setCssStyle('gap', gap); 18 | return obj; 19 | }, 20 | 21 | setTemplateColumns(template: Refable) { 22 | obj.setCssStyle('gridTemplateColumns', template); 23 | return obj; 24 | }, 25 | setDynamicColumns(minSize: Refable = 6, max: Refable = "1fr") { 26 | obj.setCssStyle('gridTemplateColumns', ref`repeat(auto-fit,minmax(${minSize}rem,${max}))`); 27 | return obj; 28 | }, 29 | setEvenColumns(count: Refable, size: string = "1fr") { 30 | obj.setCssStyle('gridTemplateColumns', alwaysRef(count).map(counted => `${size} `.repeat(counted))); 31 | return obj; 32 | }, 33 | 34 | setAutoRow(row: Refable) { 35 | obj.setCssStyle("gridAutoRows", row); 36 | return obj; 37 | }, 38 | setAutoColumn(column: Refable) { 39 | obj.setCssStyle("gridAutoColumns", column); 40 | return obj; 41 | }, 42 | setAutoFlow(type: Refable<"column" | "row" | "row-reverse" | "column-reverse">) { 43 | obj.setCssStyle("gridAutoFlow", type); 44 | return obj; 45 | } 46 | }; 47 | return obj; 48 | }; 49 | } 50 | 51 | export function Grid(component: Reference | Component, ...components: Component[]) { 52 | return new GridComponent(component, components).make(); 53 | } -------------------------------------------------------------------------------- /core/layout/label.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, HTMLComponent } from "../components.ts"; 2 | import { alwaysRef, Refable } from "../state.ts"; 3 | 4 | @asWebGenComponent("label") 5 | export class LabelComponent extends HTMLComponent { 6 | constructor(label: Refable) { 7 | super(); 8 | this.useListener(alwaysRef(label), label => this.shadowRoot!.textContent = label); 9 | } 10 | } 11 | 12 | export function Label(label: Refable) { 13 | return new LabelComponent(label).make(); 14 | } -------------------------------------------------------------------------------- /core/layout/list.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component, HTMLComponent } from "../components.ts"; 2 | import { alwaysRef, asRef, Refable, Reference } from "../state.ts"; 3 | import { Box } from "./box.ts"; 4 | 5 | @asWebGenComponent("list") 6 | export class ListComponent extends HTMLComponent { 7 | #gap = asRef(0); 8 | constructor(source: Reference, itemHeight: number, renderItem: (data: Data, index: number) => Component) { 9 | super(); 10 | 11 | this.style.display = "grid"; 12 | this.style.gridTemplate = "100% / 100%"; 13 | 14 | const windowHeight = asRef(300); 15 | const scrollTop = asRef(0); 16 | const realItemHeight = this.#gap.map(gap => itemHeight + gap); 17 | const innerHeight = source.map(it => it.length * realItemHeight.value + this.#gap.value); 18 | const startIndex = scrollTop.map(scrollTop => Math.floor(scrollTop / realItemHeight.value)); 19 | const endIndex = scrollTop.map(scrollTop => Math.min( 20 | source.value.length - 1, // don't render past the end of the list 21 | Math.floor((scrollTop + windowHeight.value) / realItemHeight.value) 22 | )); 23 | 24 | const renderingItem = asRef(Box(asRef([]))); 25 | 26 | this.addListen(() => { 27 | const items: Component[] = []; 28 | for (let i = startIndex.value; i <= endIndex.value; i++) { 29 | const item = renderItem(source.value[ i ], i).draw(); 30 | item.style.position = "absolute"; 31 | item.style.top = `${i * realItemHeight.value + (this.#gap.value / 2)}px`; 32 | item.style.width = "100%"; 33 | item.style.boxSizing = "border-box"; 34 | item.style.height = `${itemHeight}px`; 35 | items.push({ draw: () => item }); 36 | } 37 | 38 | const inner = Box(asRef(items)).addClass("inner").draw(); 39 | inner.style.position = "relative"; 40 | inner.style.height = `${innerHeight.value}px`; 41 | inner.style.display = "block"; 42 | renderingItem.setValue({ draw: () => inner }); 43 | }); 44 | 45 | 46 | this.style.overflowY = "auto"; 47 | 48 | this.useEventListener(this, "scroll", () => { 49 | scrollTop.setValue(this.scrollTop); 50 | }); 51 | 52 | this.addWatch(() => { 53 | const res = new ResizeObserver(() => { 54 | windowHeight.setValue(this.clientHeight); 55 | }); 56 | 57 | res.observe(this); 58 | 59 | return { 60 | unlisten: () => { 61 | res.disconnect(); 62 | } 63 | }; 64 | }); 65 | 66 | 67 | this.useListener(renderingItem, (newValue, oldValue) => { 68 | if (oldValue) { 69 | this.shadowRoot!.removeChild(oldValue.draw()); 70 | } 71 | this.shadowRoot!.append(newValue.draw()); 72 | }); 73 | } 74 | 75 | make() { 76 | const obj = { 77 | ...super.make(), 78 | setGap: (gap: Refable) => { 79 | this.useListener(alwaysRef(gap), (newValue) => { 80 | this.#gap.value = newValue; 81 | this.style.margin = `0 calc(0px - ${newValue}px)`; 82 | this.style.padding = `0 ${newValue}px`; 83 | }); 84 | return obj; 85 | } 86 | }; 87 | return obj; 88 | } 89 | } 90 | 91 | export function List(source: Reference, itemHeight: number, renderItem: (data: Data, index: number) => Component) { 92 | return new ListComponent(source, itemHeight, renderItem).make(); 93 | } -------------------------------------------------------------------------------- /core/layout/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./async.ts"; 2 | export * from "./box.ts"; 3 | export * from "./content.ts"; 4 | export * from "./empty.ts"; 5 | export * from "./grid.ts"; 6 | export * from "./label.ts"; 7 | export * from "./list.ts"; 8 | export * from "./popover.ts"; 9 | export * from "./slot.ts"; 10 | -------------------------------------------------------------------------------- /core/layout/popover.ts: -------------------------------------------------------------------------------- 1 | import { asWebGenComponent, Component, HTMLComponent } from "../components.ts"; 2 | import { asRef } from "../state.ts"; 3 | 4 | @asWebGenComponent("popover") 5 | export class PopoverComponent extends HTMLComponent { 6 | #requestedOpen = asRef(false); 7 | #open = asRef(false); 8 | constructor(content: Component) { 9 | super(); 10 | this.setAttribute("popover", ""); 11 | this.shadowRoot!.append(content.draw()); 12 | this.addEventListener("toggle", (event) => { 13 | this.#open.setValue((event).newState === "open"); 14 | }); 15 | } 16 | 17 | override make() { 18 | const obj = { 19 | ...super.make(), 20 | showPopover: () => { 21 | try { 22 | this.showPopover(); 23 | this.#requestedOpen.value = true; 24 | } catch { 25 | // 26 | } 27 | return obj; 28 | }, 29 | hidePopover: () => { 30 | try { 31 | this.hidePopover(); 32 | this.#requestedOpen.value = false; 33 | } catch { 34 | // 35 | } 36 | return obj; 37 | }, 38 | togglePopover: (force: boolean) => { 39 | try { 40 | this.togglePopover(force); 41 | this.#requestedOpen.value = !this.#requestedOpen.value; 42 | } catch { 43 | // 44 | } 45 | return obj; 46 | }, 47 | isOpen: () => { 48 | return this.#open.getValue(); 49 | }, 50 | }; 51 | return obj; 52 | } 53 | } 54 | 55 | export function Popover(content: Component) { 56 | return new PopoverComponent(content).make(); 57 | } -------------------------------------------------------------------------------- /core/layout/slot.ts: -------------------------------------------------------------------------------- 1 | import type { Component } from "../components.ts"; 2 | 3 | export function Slot(name?: string): Component { 4 | const slot = document.createElement("slot"); 5 | if (name) { 6 | slot.name = name; 7 | } 8 | return { 9 | draw() { 10 | return slot; 11 | } 12 | }; 13 | } -------------------------------------------------------------------------------- /core/lazy.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Lazily evaluates a callback function and caches its result. 3 | * 4 | * 5 | * @example ```ts 6 | * const lazyValue = lazy(() => { 7 | * console.log("This will only be logged once."); 8 | * return 42; 9 | * }); 10 | * 11 | * console.log(lazyValue()); // Logs "This will only be logged once." and prints 42. 12 | * console.log(lazyValue()); // Prints 42. 13 | * ``` 14 | * @template T The type of the result returned by the callback function. 15 | * @param callback A function that returns the result of the lazy evaluation. 16 | * @returns A function that, when called, returns the cached result of the callback function. 17 | */ 18 | export const lazy = (callback: () => T) => { 19 | let result: T = undefined!; 20 | return () => result = (result || callback()); 21 | }; 22 | -------------------------------------------------------------------------------- /core/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./color.ts"; 2 | export * from "./components.ts"; 3 | export * from "./cssTemplate.ts"; 4 | export * from "./layout/mod.ts"; 5 | export * from "./lazy.ts"; 6 | export * from "./state.ts"; 7 | export * from "./types.ts"; 8 | -------------------------------------------------------------------------------- /core/state.ts: -------------------------------------------------------------------------------- 1 | import { Signal } from "https://esm.sh/signal-polyfill@0.2.2"; 2 | 3 | export function isRef(obj: unknown): obj is Reference { 4 | return ( 5 | typeof obj === 'object' && 6 | obj !== null && 7 | 'listen' in obj 8 | ); 9 | } 10 | 11 | export interface RefEvent { 12 | (value: Type, oldValue?: Type): void; 13 | } 14 | 15 | export interface Reference { 16 | getValue(): T; 17 | value: T; 18 | listen(c: RefEvent): { unlisten: () => void; }; 19 | map(mapper: (val: T) => NewType): Reference; 20 | } 21 | 22 | export class WriteSignal extends Signal.State implements Reference { 23 | getValue(): T { 24 | return this.get(); 25 | } 26 | setValue(val: T): void { 27 | this.set(val); 28 | } 29 | get value(): T { 30 | return this.get(); 31 | } 32 | set value(val: T) { 33 | this.set(val); 34 | } 35 | listen(c: RefEvent): { unlisten: () => void; } { 36 | let oldValue = undefined as T; 37 | return listen(() => { 38 | if (oldValue === this.get()) return; 39 | c(this.get(), oldValue); 40 | oldValue = this.get(); 41 | }); 42 | } 43 | map(mapper: (val: T) => NewType): Reference { 44 | return new ReadSignal(() => { 45 | return mapper(this.get()); 46 | }); 47 | } 48 | } 49 | 50 | export class ArrayWriteSignal extends WriteSignal { 51 | 52 | constructor(initValue: T, options?: Signal.Options) { 53 | super(initValue, options); 54 | } 55 | 56 | get value(): Readonly { 57 | return super.get(); 58 | } 59 | 60 | set value(value: T) { 61 | super.setValue(value); 62 | } 63 | 64 | getValue(): Readonly { 65 | return super.getValue(); 66 | } 67 | get(): Readonly { 68 | return super.get(); 69 | } 70 | addItem(item: D) { 71 | this.value = [ ...this.value, item ] as T; 72 | } 73 | removeItem(item: D) { 74 | this.value = this.value.filter(it => it !== item) as T; 75 | } 76 | } 77 | 78 | export class ReadSignal extends Signal.Computed implements Reference { 79 | getValue(): T { 80 | return this.get(); 81 | } 82 | get value(): T { 83 | return this.get(); 84 | } 85 | listen(c: RefEvent): { unlisten: () => void; } { 86 | let oldValue = undefined as T; 87 | return listen(() => { 88 | if (oldValue === this.get()) return; 89 | c(this.get(), oldValue); 90 | oldValue = this.get(); 91 | }); 92 | } 93 | map(mapper: (val: T) => NewType): Reference { 94 | return new ReadSignal(() => { 95 | return mapper(this.get()); 96 | }); 97 | } 98 | } 99 | 100 | 101 | export let WEBGEN_LISTEN_COUNT = 0; 102 | let pending = false; 103 | 104 | const watcher = new Signal.subtle.Watcher(() => { 105 | if (!pending) { 106 | pending = true; 107 | let countOfRepeats = 0; 108 | 109 | const triggerUpdate = () => { 110 | queueMicrotask(() => { 111 | for (const s of watcher.getPending()) s.get(); 112 | watcher.watch(); 113 | if (countOfRepeats > 1000) { 114 | console.error("Infinite Loop Detected"); 115 | return; 116 | } 117 | if (watcher.getPending().length > 0) { 118 | countOfRepeats++; 119 | triggerUpdate(); 120 | } else { 121 | pending = false; 122 | if (countOfRepeats > 10) { 123 | console.warn("Large Waterfall of Updates Detected! Count:", countOfRepeats); 124 | return; 125 | } 126 | } 127 | }); 128 | }; 129 | triggerUpdate(); 130 | } 131 | }); 132 | 133 | export function listen(callback: () => void): { unlisten: () => void; } { 134 | WEBGEN_LISTEN_COUNT++; 135 | if (WEBGEN_LISTEN_COUNT % 1000 === 0) { 136 | console.debug("Leaking Listener? Found:", WEBGEN_LISTEN_COUNT); 137 | } 138 | const computed = new Signal.Computed(() => { callback(); }); 139 | watcher.watch(computed); 140 | computed.get(); 141 | return { 142 | unlisten: () => { 143 | WEBGEN_LISTEN_COUNT--; 144 | watcher.unwatch(computed); 145 | } 146 | }; 147 | } 148 | 149 | export function asRef(initValue: T, options?: Signal.Options): WriteSignal { 150 | return new WriteSignal(initValue, options); 151 | } 152 | 153 | export function asRefArray(initValue: T[], options?: Signal.Options): ArrayWriteSignal { 154 | return new ArrayWriteSignal(initValue, options); 155 | } 156 | 157 | export type RefRecord = { [ Key in keyof T ]: WriteSignal } 158 | 159 | // deno-lint-ignore no-explicit-any 160 | export function asRefRecord(initValue: T, options?: Signal.Options): RefRecord { 161 | const keys = Object.keys(initValue) as (keyof T)[]; 162 | const result = {} as { [ Key in keyof T ]: WriteSignal }; 163 | for (const key of keys) { 164 | result[ key ] = asRef(initValue[ key ], options); 165 | } 166 | return result; 167 | } 168 | 169 | export function asDeepRef(initValue: T, options?: Signal.Options): DeepWriteSignal { 170 | return new DeepWriteSignal(initValue, options); 171 | } 172 | 173 | export function alwaysRef(value: Refable): Reference { 174 | if (isRef(value)) 175 | return value; 176 | return new ReadSignal(() => value); 177 | } 178 | 179 | export function fromRefs(callback: () => T): Reference { 180 | return new ReadSignal(callback); 181 | } 182 | 183 | function nestedProxy(object: T, callback: () => void) { 184 | const handler: ProxyHandler = { 185 | get(target, key) { 186 | if (key == 'isProxy') 187 | return true; 188 | 189 | const prop = target[ key as keyof T ]; 190 | 191 | // return if property not found 192 | if (typeof prop == 'undefined') 193 | return; 194 | 195 | // set value as proxy if object 196 | if (!prop![ 'isProxy' as keyof typeof prop ] && typeof prop === 'object') 197 | // @ts-ignore : works 198 | target[ key as keyof T ] = new Proxy(prop as object, handler); 199 | 200 | return target[ key as keyof T ]; 201 | }, 202 | set(target, key, value) { 203 | callback(); 204 | target[ key as keyof T ] = value; 205 | return true; 206 | } 207 | }; 208 | 209 | const proxy = new Proxy(object, handler); 210 | 211 | return proxy; 212 | } 213 | 214 | export class DeepWriteSignal extends WriteSignal { 215 | set value(value: T) { 216 | super.setValue(value); 217 | } 218 | get value(): T { 219 | return nestedProxy(super.value, () => this.set(this.value)); 220 | } 221 | } 222 | 223 | export type Refable = T | Reference; 224 | 225 | /** 226 | * Creates a Reference from a tagged templates 227 | * 228 | * ref\`Hello World\` => a Reference of Hello World 229 | * 230 | * ref\`Hello ${state.user}\` => a Reference of Hello and the static value of user 231 | * 232 | * ref\`Hello ${state.$user}\` => a Reference of Hello and the current value of user (Reference reacts on Reference) 233 | */ 234 | export function ref(data: TemplateStringsArray, ...expr: Refable[]): Reference { 235 | const empty = Symbol("empty"); 236 | const merge = data.map((x, i) => [ x, expr[ i ] ?? empty ]).flat(); 237 | 238 | return fromRefs(() => { 239 | let list = ""; 240 | for (const iterator of merge) { 241 | if (iterator === empty) continue; 242 | if (isRef(iterator)) 243 | list += iterator.getValue(); 244 | else 245 | list += iterator; 246 | } 247 | return list; 248 | }); 249 | } -------------------------------------------------------------------------------- /core/types.ts: -------------------------------------------------------------------------------- 1 | export type TextSize = 'xs' | 'sm' | 'base' | 'lg' | 'xl' | '2xl' | '3xl' | '4xl' | '5xl' | '6xl' | '7xl' | '8xl' | '9xl'; 2 | export type FontWeight = | 'thin' | 'extralight' | 'light' | 'normal' | 'medium' | 'semibold' | 'bold' | 'extrabold' | 'black'; 3 | export type Radius = 'tiny' | 'mid' | 'large' | 'extra' | 'complete' | 'none'; -------------------------------------------------------------------------------- /deno.jsonc: -------------------------------------------------------------------------------- 1 | { 2 | "fmt": { 3 | "options": { 4 | "indentWidth": 4 5 | } 6 | }, 7 | "lock": false, 8 | "compilerOptions": { 9 | "lib": [ 10 | "dom", 11 | "dom.iterable", 12 | "dom.asynciterable", 13 | "deno.ns" 14 | ] 15 | }, 16 | "tasks": { 17 | "update": "deno run -A jsr:@molt/cli **/*.ts", 18 | "update:commit": "deno task -q update --commit" 19 | } 20 | } -------------------------------------------------------------------------------- /extended/filePicker.ts: -------------------------------------------------------------------------------- 1 | export async function createFilePicker(accept: string): Promise { 2 | const fileSignal = Promise.withResolvers(); 3 | const input = document.createElement("input"); 4 | input.type = "file"; 5 | input.hidden = true; 6 | input.accept = accept; 7 | 8 | input.addEventListener("change", () => { 9 | fileSignal.resolve(Array.from(input.files ?? [])[ 0 ]!); 10 | }); 11 | 12 | input.showPicker(); 13 | return await fileSignal.promise; 14 | } 15 | -------------------------------------------------------------------------------- /extended/fromFormEntries.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * fromEntries can't handle duplicates 3 | * 4 | * this method turns 5 | * 6 | * ["key", "value"] 7 | * ["list", "one"] 8 | * ["list", "two"] 9 | * 10 | * to 11 | * 12 | * { 13 | * key: "value" 14 | * list: ["one", "two"] 15 | * } 16 | */ 17 | export function fromFormEntries(data: [ key: string, value: FormDataEntryValue ][]) { 18 | const entries = Object.entries(Object.groupBy(data, ([ key ]) => key)) as [ key: string, value: [ key: string, value: FormDataEntryValue ][] ][]; 19 | const pureEntries = entries.map(([ key, value ]) => { 20 | const values = value.map(([ _, data ]) => data); 21 | if (values.length == 1) 22 | return [ key, values[ 0 ] ]; 23 | return [ key, values ]; 24 | }); 25 | return { 26 | ...Object.fromEntries(pureEntries) 27 | }; 28 | } -------------------------------------------------------------------------------- /extended/iterableWeakMap.ts: -------------------------------------------------------------------------------- 1 | // deno-lint-ignore no-explicit-any 2 | export class IterableWeakMap[]> { 3 | #weakMap = new WeakMap(); 4 | // deno-lint-ignore no-explicit-any 5 | #refSet = new Set>(); 6 | #finalizationGroup = new FinalizationRegistry(IterableWeakMap.#cleanup); 7 | 8 | // deno-lint-ignore no-explicit-any 9 | static #cleanup({ set, ref }: { set: Set>, ref: WeakRef; }) { 10 | set.delete(ref); 11 | } 12 | 13 | constructor(iterable: Iterable) { 14 | for (const [ key, value ] of iterable) { 15 | this.set(key, value); 16 | } 17 | } 18 | 19 | // deno-lint-ignore no-explicit-any 20 | set(key: any, value: any) { 21 | const ref = new WeakRef(key); 22 | 23 | this.#weakMap.set(key, { value, ref }); 24 | this.#refSet.add(ref); 25 | this.#finalizationGroup.register(key, { 26 | set: this.#refSet, 27 | ref 28 | }, ref); 29 | } 30 | 31 | // deno-lint-ignore no-explicit-any 32 | get(key: any) { 33 | const entry = this.#weakMap.get(key); 34 | return entry && entry.value; 35 | } 36 | 37 | // deno-lint-ignore no-explicit-any 38 | delete(key: any) { 39 | const entry = this.#weakMap.get(key); 40 | if (!entry) { 41 | return false; 42 | } 43 | 44 | this.#weakMap.delete(key); 45 | this.#refSet.delete(entry.ref); 46 | this.#finalizationGroup.unregister(entry.ref); 47 | return true; 48 | } 49 | 50 | *[ Symbol.iterator ]() { 51 | for (const ref of this.#refSet) { 52 | const key = ref.deref(); 53 | if (!key) continue; 54 | const { value } = this.#weakMap.get(key); 55 | yield [ key, value ]; 56 | } 57 | } 58 | 59 | entries() { 60 | return this[ Symbol.iterator ](); 61 | } 62 | 63 | *keys() { 64 | for (const [ key ] of this) { 65 | yield key; 66 | } 67 | } 68 | 69 | *values() { 70 | for (const [ , value ] of this) { 71 | yield value; 72 | } 73 | } 74 | } -------------------------------------------------------------------------------- /extended/keyValueStore.ts: -------------------------------------------------------------------------------- 1 | export async function createKeyValue(collectionName: string) { 2 | const db = await new Promise((resolve, reject) => { 3 | const request = indexedDB.open("webgen-keyval"); 4 | 5 | request.onerror = () => { 6 | reject(new Error("Failed to open IndexedDB")); 7 | }; 8 | 9 | request.onsuccess = () => { 10 | resolve(request.result); 11 | }; 12 | request.onupgradeneeded = function () { 13 | request.result.createObjectStore(collectionName); 14 | }; 15 | 16 | }); 17 | 18 | // Set a value in the map 19 | const set = (key: string, value: T | undefined) => { 20 | return new Promise((resolve, reject) => { 21 | const transaction = db.transaction([ collectionName ], "readwrite"); 22 | const store = transaction.objectStore(collectionName); 23 | 24 | transaction.onerror = () => { 25 | reject(new Error("Error in IndexedDB transaction")); 26 | }; 27 | 28 | transaction.oncomplete = () => { 29 | resolve(); 30 | }; 31 | 32 | store.put(value, key); 33 | }); 34 | }; 35 | 36 | // Get a value from the map 37 | const get = (key: string) => { 38 | return new Promise((resolve, reject) => { 39 | const transaction = db.transaction([ collectionName ], "readonly"); 40 | const store = transaction.objectStore(collectionName); 41 | 42 | const request = store.get(key); 43 | 44 | request.onerror = () => { 45 | reject(new Error("Error retrieving value from IndexedDB")); 46 | }; 47 | 48 | request.onsuccess = () => { 49 | resolve(request.result); 50 | }; 51 | }); 52 | }; 53 | 54 | 55 | // Get a value from the map 56 | const has = (key: string) => { 57 | return new Promise((resolve, reject) => { 58 | const transaction = db.transaction([ collectionName ], "readonly"); 59 | const store = transaction.objectStore(collectionName); 60 | 61 | const request = store.get(key); 62 | 63 | request.onerror = () => { 64 | reject(new Error("Error retrieving value from IndexedDB")); 65 | }; 66 | 67 | request.onsuccess = () => { 68 | resolve(request.result !== undefined); 69 | }; 70 | }); 71 | }; 72 | 73 | // Clear the keyvalue 74 | const clear = () => { 75 | return new Promise((resolve, reject) => { 76 | const transaction = db.transaction([ collectionName ], "readwrite"); 77 | const store = transaction.objectStore(collectionName); 78 | 79 | const request = store.clear(); 80 | 81 | request.onerror = () => { 82 | reject(new Error("Error clearing IndexedDB store")); 83 | }; 84 | 85 | request.onsuccess = () => { 86 | resolve(); 87 | }; 88 | }); 89 | }; 90 | 91 | // Delete a key from the map 92 | const remove = (key: string) => { 93 | return new Promise((resolve, reject) => { 94 | const transaction = db.transaction([ collectionName ], "readwrite"); 95 | const store = transaction.objectStore(collectionName); 96 | 97 | const request = store.delete(key); 98 | 99 | request.onerror = () => { 100 | reject(new Error("Error deleting key from IndexedDB")); 101 | }; 102 | 103 | request.onsuccess = () => { 104 | resolve(); 105 | }; 106 | }); 107 | }; 108 | 109 | return { 110 | set, 111 | get, 112 | has, 113 | clear, 114 | remove 115 | }; 116 | } -------------------------------------------------------------------------------- /extended/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./filePicker.ts"; 2 | export * from "./fromFormEntries.ts"; 3 | export * from "./iterableWeakMap.ts"; 4 | export * from "./keyValueStore.ts"; 5 | export * from "./network.ts"; 6 | export * from "./scheduler.ts"; 7 | export * from "./stableRequests.ts"; 8 | export * from "./stableWebSockets.ts"; 9 | -------------------------------------------------------------------------------- /extended/network.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "jsr:@std/assert@^1.0.0"; 2 | import { delay, pooledMap } from "jsr:@std/async@^1.0.0"; 3 | import { MINUTE } from "jsr:@std/datetime@^0.225.0"; 4 | import { asRef, Reference } from "../core/mod.ts"; 5 | import { createScheduler, SchedulerPriority } from "./scheduler.ts"; 6 | 7 | export interface PaginationObject { 8 | reset: () => void; 9 | next: () => Promise<{ items: T[], hasMore: boolean; }>; 10 | } 11 | 12 | export interface CachedPages extends PaginationObject { 13 | items: Reference; 14 | hasMore: Reference; 15 | } 16 | 17 | export function createIndexPaginationLoader(options: { 18 | limit: number; 19 | loader: (offset: number, limit: number) => Promise; 20 | }): PaginationObject { 21 | let offset = 0; 22 | let hasMore = true; 23 | return { 24 | reset: () => { 25 | offset = 0; 26 | hasMore = true; 27 | }, 28 | next: async () => { 29 | const items = await options.loader(offset, options.limit + 1); 30 | hasMore = items.length > options.limit; 31 | if (hasMore) 32 | items.pop(); 33 | offset += items.length; 34 | return { items, hasMore }; 35 | } 36 | }; 37 | } 38 | 39 | export function createCachedLoader(source: PaginationObject): CachedPages { 40 | const items = asRef([]); 41 | const hasMore = asRef(true); 42 | return { 43 | items, 44 | hasMore, 45 | reset: () => { 46 | source.reset(); 47 | items.setValue([]); 48 | hasMore.setValue(true); 49 | }, 50 | next: async () => { 51 | const response = await source.next(); 52 | hasMore.setValue(response.hasMore); 53 | items.setValue([ ...items.getValue(), ...response.items ]); 54 | return response; 55 | } 56 | }; 57 | } 58 | 59 | interface Deferred { 60 | promise: Promise; 61 | resolve(value?: T | PromiseLike): void; 62 | // deno-lint-ignore no-explicit-any 63 | reject(reason?: any): void; 64 | } 65 | 66 | export interface Task { 67 | priority: SchedulerPriority, 68 | request: Request, 69 | completed: Deferred, 70 | } 71 | 72 | export enum ThrottleStrategy { 73 | Static, 74 | Dynamic 75 | } 76 | 77 | export type ThrottledPipelineOptions = ( 78 | | { strategy: ThrottleStrategy.Dynamic; } 79 | | { strategy: ThrottleStrategy.Static; throughputPerMinute: number; }) 80 | & { curve?: number; concurrency?: number; }; 81 | 82 | export function createThrottledPipeline(options: ThrottledPipelineOptions) { 83 | const tasks = createScheduler(); 84 | 85 | let nextReset = Date.now(); 86 | let remaining = options.strategy === ThrottleStrategy.Static ? options.throughputPerMinute : 0; 87 | if (options.strategy === ThrottleStrategy.Static) 88 | setInterval(() => { 89 | nextReset = Date.now(); 90 | remaining = options.strategy === ThrottleStrategy.Static ? options.throughputPerMinute : 0; 91 | }, 1 * MINUTE); 92 | 93 | 94 | let lastHeader: Headers | undefined = undefined; 95 | 96 | const throttler = new class ThrottledStream extends TransformStream { 97 | constructor() { 98 | super({ 99 | transform: async (task, controller) => { 100 | const curve = options.curve ?? 1.5; 101 | 102 | if (options.strategy === ThrottleStrategy.Dynamic) { 103 | const headers = lastHeader; 104 | if (headers) { 105 | const limit = Number(headers.get("X-Ratelimit-Limit") ?? headers.get("X-Rate-Limit-Limit")); // How many requests we can make in this minute 106 | const remaining = Number(headers.get("X-Ratelimit-Remaining") ?? headers.get("X-Rate-Limit-Remaining")); // How many requests we have left in this minute 107 | const reset = Number(headers.get("X-Ratelimit-Reset") ?? headers.get("X-Rate-Limit-Reset")); // How many seconds until the ratelimit resets 108 | assert(!isNaN(limit) || !isNaN(remaining) || !isNaN(reset), "Ratelimit headers are not numbers"); 109 | 110 | await delay(Math.pow((1 - (remaining / limit)), curve) * reset * 1000); 111 | } 112 | } else { 113 | const reset = (Date.now() - nextReset) / 1000; 114 | await delay(Math.pow((1 - (remaining / options.throughputPerMinute)), curve) * reset * 1000); 115 | remaining--; 116 | } 117 | controller.enqueue(task); 118 | } 119 | }); 120 | } 121 | 122 | 123 | }; 124 | 125 | const sourceStream = tasks.scheduler 126 | .pipeThrough(throttler); 127 | 128 | pooledMap(options.concurrency ?? 1, 129 | sourceStream, 130 | async (task) => { 131 | try { 132 | const response = await fetch(task.request, { 133 | mode: options.strategy === ThrottleStrategy.Dynamic ? "no-cors" : "cors" 134 | }); 135 | task.completed.resolve(response); 136 | lastHeader = response.headers; 137 | } catch (error) { 138 | task.completed.reject(error); 139 | } 140 | } 141 | ); 142 | 143 | return { 144 | fetch: (priority: SchedulerPriority, request: Request | string) => { 145 | const completed = Promise.withResolvers(); 146 | const requestObj = typeof request === "string" ? new Request(request) : request; 147 | tasks.add({ 148 | priority, 149 | request: requestObj, 150 | completed 151 | }); 152 | return completed.promise; 153 | } 154 | }; 155 | } -------------------------------------------------------------------------------- /extended/reorder.ts: -------------------------------------------------------------------------------- 1 | export function reorderItemInArrayBasedOnIndex(array: T[], sourceIndex: number, destinationIndex: number): T[] { 2 | const newArray = [ ...array ]; 3 | // Remove the item from the source index and insert it at the destination index. 4 | newArray.splice(destinationIndex, 0, newArray.splice(sourceIndex, 1)[ 0 ]); 5 | return newArray; 6 | } -------------------------------------------------------------------------------- /extended/scheduler.ts: -------------------------------------------------------------------------------- 1 | import { sortBy } from "jsr:@std/collections@^1.0.0"; 2 | 3 | export enum SchedulerPriority { 4 | Low = 2, 5 | Medium = 1, 6 | High = 0 7 | } 8 | 9 | export function createScheduler() { 10 | let hasTask = Promise.withResolvers(); 11 | const queue: T[] = []; 12 | const scheduler = new ReadableStream({ 13 | pull: async (controller) => { 14 | if (queue.length === 0) { 15 | hasTask = Promise.withResolvers(); 16 | await hasTask.promise; 17 | } 18 | const task = sortBy(queue, it => it.priority)[ 0 ]; 19 | controller.enqueue(task); 20 | queue.splice(queue.indexOf(task), 1); 21 | } 22 | }); 23 | return { 24 | scheduler, 25 | add: (object: T) => { 26 | queue.push(object); 27 | hasTask.resolve(); 28 | } 29 | }; 30 | } 31 | -------------------------------------------------------------------------------- /extended/stableRequests.ts: -------------------------------------------------------------------------------- 1 | import { assert } from "jsr:@std/assert@^1.0.0"; 2 | import { retry } from "jsr:@std/async@^1.0.0"; 3 | 4 | export type StableRequest = { 5 | request: Request; 6 | failOnNetworkError?: boolean; 7 | retryOnHttpError?: boolean; 8 | }; 9 | 10 | export function createStableRequest({ 11 | request, 12 | failOnNetworkError = false, 13 | retryOnHttpError = false, 14 | }: StableRequest): Promise { 15 | return retry(async () => { 16 | const response = await retry(() => fetch(request), { 17 | maxAttempts: failOnNetworkError ? 1 : 3, 18 | minTimeout: 100 19 | }); 20 | 21 | if (retryOnHttpError) 22 | assert(response.ok); 23 | 24 | return response; 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /extended/stableWebSockets.ts: -------------------------------------------------------------------------------- 1 | import { retry } from "jsr:@std/async@^1.0.0"; 2 | 3 | export type WebSocketContext = { 4 | send: (data: string | ArrayBufferLike | Blob | ArrayBufferView) => void; 5 | close: () => void; 6 | }; 7 | 8 | export async function createStableWebSocket(connection: { 9 | url: string; 10 | protocol?: string | string[]; 11 | }, events: { 12 | onReconnect?: () => void; 13 | onMessage?: (message: string | ArrayBuffer) => void; 14 | }): Promise { 15 | let socket: WebSocket | undefined = undefined; 16 | let close = false; 17 | const pooledMessages: (string | ArrayBufferLike | Blob | ArrayBufferView)[] = []; 18 | function send(data: string | ArrayBufferLike | Blob | ArrayBufferView) { 19 | pooledMessages.push(data); 20 | } 21 | const socketFirstTimeConnected = Promise.withResolvers(); 22 | console.debug(connection.url, "start"); 23 | (async () => { 24 | try { 25 | while (!close) { 26 | await retry(async () => { 27 | const socketClosed = Promise.withResolvers(); 28 | const websocket = new WebSocket(connection.url, connection.protocol); 29 | socket = websocket; 30 | let activePool: undefined | number; 31 | websocket.addEventListener("open", () => { 32 | console.debug(connection.url, "connected"); 33 | activePool = setInterval(() => { 34 | if (pooledMessages.length > 0) { 35 | websocket.send(pooledMessages.shift()!); 36 | } 37 | }, 10); 38 | events.onReconnect?.(); 39 | socketFirstTimeConnected.resolve(); 40 | }, { once: true }); 41 | 42 | websocket.addEventListener("close", () => { 43 | console.debug(connection.url, "close"); 44 | clearInterval(activePool); 45 | socketClosed.reject(); 46 | }, { once: true }); 47 | 48 | websocket.addEventListener("error", (error) => { 49 | console.debug(connection.url, "error", error); 50 | }); 51 | 52 | websocket.addEventListener("message", (message) => { 53 | events.onMessage?.(message.data); 54 | }); 55 | await socketClosed.promise; 56 | }); 57 | } 58 | } catch (error) { 59 | console.error("Bad State", error); 60 | } 61 | })(); 62 | await socketFirstTimeConnected.promise; 63 | return { 64 | send, 65 | close: () => { 66 | close = true; 67 | socket?.close(); 68 | } 69 | }; 70 | } 71 | -------------------------------------------------------------------------------- /mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./core/mod.ts"; 2 | 3 | export * from "./components/mod.ts"; 4 | export * from "./extended/mod.ts"; 5 | export * from "./navigation/mod.ts"; 6 | -------------------------------------------------------------------------------- /navigation/Menu.ts: -------------------------------------------------------------------------------- 1 | import { sortBy } from "jsr:@std/collections@^1.0.0"; 2 | import { asRefArray, Refable } from "../core/mod.ts"; 3 | import { AnyRoute } from "./Route.ts"; 4 | 5 | export type MenuEntry = { 6 | route: AnyRoute; 7 | label: Refable; 8 | weight?: number; 9 | }; 10 | 11 | export const MenuRegistry = asRefArray([]); 12 | 13 | export const menuList = MenuRegistry.map(items => sortBy(items, item => item.weight ?? 0)); -------------------------------------------------------------------------------- /navigation/Navigation.ts: -------------------------------------------------------------------------------- 1 | import "https://esm.sh/v135/@types/dom-navigation@1.0.4/index.d.ts"; 2 | import { sortBy } from "jsr:@std/collections@^1.0.0"; 3 | import { asRef, asRefArray } from "../core/state.ts"; 4 | 5 | export const PageIsLoading = asRef(false); 6 | 7 | export type NavigationEntry = { 8 | weight: number; 9 | intercept: (url: URL, event: NavigateEvent) => boolean | void; 10 | }; 11 | 12 | export const NavigationRegistry = asRefArray([]); 13 | 14 | function shouldNotIntercept(navigationEvent: NavigateEvent) { 15 | return ( 16 | // If this is just a hashChange, 17 | // just let the browser handle scrolling to the content. 18 | navigationEvent.hashChange || 19 | // If this is a download, 20 | // let the browser perform the download. 21 | navigationEvent.downloadRequest || 22 | // If this is a form submission, 23 | // let that go to the server. 24 | navigationEvent.formData || 25 | // If this is a reload, 26 | // let the browser handle it. 27 | navigationEvent.navigationType === "reload" 28 | ); 29 | } 30 | 31 | declare global { 32 | // deno-lint-ignore no-var 33 | var navigation: Navigation; 34 | } 35 | 36 | navigation.addEventListener('navigate', navigateEvent => { 37 | if (shouldNotIntercept(navigateEvent)) return; 38 | PageIsLoading.setValue(true); 39 | 40 | const url = new URL(navigateEvent.destination.url); 41 | 42 | for (const entry of sortBy(NavigationRegistry.getValue(), it => it.weight)) { 43 | const result = entry.intercept(url, navigateEvent); 44 | if (result === false) return; 45 | } 46 | }); 47 | 48 | navigation.addEventListener('navigatesuccess', () => { 49 | PageIsLoading.setValue(false); 50 | }); 51 | 52 | navigation.addEventListener('navigateerror', event => { 53 | PageIsLoading.setValue(false); 54 | console.debug(`Failed to load page: ${event.message}`); 55 | }); -------------------------------------------------------------------------------- /navigation/Page.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "../core/components.ts"; 2 | import { Box } from "../core/mod.ts"; 3 | import { asRefArray } from "../core/state.ts"; 4 | import { MenuEntry, MenuRegistry } from "./Menu.ts"; 5 | 6 | export const PageRegistry = asRefArray<{ 7 | menuEntry: MenuEntry, 8 | page: Component; 9 | }>([]); 10 | 11 | export function createPage(menuEntry: MenuEntry, page: Component) { 12 | PageRegistry.addItem({ 13 | menuEntry: menuEntry, 14 | page: page 15 | }); 16 | MenuRegistry.addItem(menuEntry); 17 | const element = page.draw(); 18 | return { 19 | route: menuEntry.route, 20 | page: { 21 | draw: () => element 22 | } 23 | }; 24 | } 25 | 26 | export const PageRouter = Box(PageRegistry.map(pages => 27 | pages.map(page => Box(page.menuEntry.route.active.map(active => active ? page.page : []))) 28 | )).addClass("pages"); -------------------------------------------------------------------------------- /navigation/Route.ts: -------------------------------------------------------------------------------- 1 | import zod from "https://deno.land/x/zod@v3.23.8/mod.ts"; 2 | 3 | import { sortBy } from "jsr:@std/collections@^1.0.0"; 4 | import { asDeepRef, asRef, lazy, Refable, Reference } from "../core/mod.ts"; 5 | import { asRefArray } from "../core/state.ts"; 6 | import { NavigationRegistry } from "./Navigation.ts"; 7 | 8 | type Split = 9 | string extends S ? string[] : 10 | S extends '' ? [] : 11 | S extends `${infer T}${D}${infer U}` ? [ T, ...Split ] : [ S ]; 12 | 13 | type Prettify = { 14 | [ K in keyof T ]: T[ K ]; 15 | } & unknown; 16 | 17 | type TrimLeadingSlash = T extends `/${infer U}` ? U : T; 18 | 19 | type TrimTrailingSlash = T extends `${infer U}/` ? U : T; 20 | 21 | type RouteOptions = { 22 | path: Path; 23 | search?: T; 24 | inAccessible?: Refable; 25 | events?: { 26 | onLazyInit?: () => void | Promise; 27 | onActive?: () => void | Promise; 28 | onInactive?: () => void; 29 | }; 30 | }; 31 | 32 | type RouteEntry = { 33 | pattern: URLPattern; 34 | patternUrl: string; 35 | inAccessible?: Reference; 36 | intercept: (result: URLPatternResult) => Promise; 37 | }; 38 | 39 | // Filter out string that start with a colon 40 | type FilterNamedParam = T extends `:${infer NamedParam}` ? NamedParam : never; 41 | 42 | type ListOfParamsToOnlyNamedParams = { 43 | [ K in keyof T ]: FilterNamedParam; 44 | }[ number ]; 45 | 46 | export type UrlPath = `/${string}`; 47 | 48 | export type Route = { 49 | entry: RouteEntry, 50 | active: Reference, 51 | groups: Prettify}`>, "/">>, string>>, 52 | search: zod.infer>, 53 | navigate: (groups: Prettify}`>, "/">>, string>>, options?: NavigationNavigateOptions) => NavigationResult, 54 | createRoute: (options: RouteOptions) => Route>, 55 | }; 56 | 57 | export type AnyRoute = Route; 58 | 59 | // deno-lint-ignore ban-types 60 | type EmptyObject = {}; 61 | 62 | export const RouteRegistry = asRefArray([]); 63 | export const activeRouteUrl = asRef(location.href); 64 | export const getRouteList = () => sortBy(RouteRegistry.getValue(), it => it.patternUrl.length).toReversed(); 65 | export const getBestRouteFromUrl = (url: string | URL) => getRouteList().filter(x => x.inAccessible?.getValue() !== false).find(route => route.pattern.test(url)); 66 | export const activeRoute = activeRouteUrl.map(url => getBestRouteFromUrl(url)); 67 | 68 | NavigationRegistry.addItem({ 69 | weight: 0, 70 | intercept: (url, event) => { 71 | const route = getBestRouteFromUrl(url); 72 | if (route) { 73 | event.intercept({ 74 | handler: () => route.intercept(route.pattern.exec(url)!).finally(() => { 75 | activeRouteUrl.setValue(url.toString()); 76 | }) 77 | }); 78 | } 79 | } 80 | }); 81 | 82 | export function createRoute(options: RouteOptions): Route { 83 | const cleanedUpPath = options.path.replace(/\/$/, ""); 84 | const pattern = new URLPattern(cleanedUpPath, location.origin); 85 | console.debug("Add Route:", cleanedUpPath); 86 | 87 | const groups = asDeepRef( 88 | Object.fromEntries(pattern.pathname 89 | .replace(/^\//, "") 90 | .split("/") 91 | .filter(x => x.startsWith(":")) 92 | .map(x => x.replace(/^:/, "")) 93 | .map(x => [ x, "" as string ] as const)) 94 | ); 95 | 96 | const search = asDeepRef(Object.fromEntries( 97 | Object.entries(options.search ?? {}) 98 | .map(([ key ]) => [ key, undefined as string | undefined ] as const) 99 | )); 100 | 101 | const lazyInitActive = lazy(options.events?.onLazyInit ?? (() => { })); 102 | const active = asRef(false); 103 | const routeEntry = { 104 | pattern, 105 | patternUrl: cleanedUpPath, 106 | inAccessible: options.inAccessible, 107 | intercept: async (patternResult) => { 108 | for (const [ key, value ] of Object.entries(patternResult.pathname.groups)) { 109 | if (value === undefined) return; 110 | groups.value[ key as keyof typeof groups.value ] = value; 111 | } 112 | const searchParams = new URLSearchParams(patternResult.search.input); 113 | for (const key of Object.keys(options.search ?? {})) { 114 | const parsing = options.search?.[ key as keyof typeof options.search ]?.safeParse(searchParams.get(key)); 115 | if (parsing?.success) 116 | search.value[ key as keyof typeof search.value ] = parsing.data; 117 | else { 118 | console.debug("Failed to parse", key, parsing?.error); 119 | return; 120 | }; 121 | } 122 | await lazyInitActive(); 123 | await options.events?.onActive?.(); 124 | active.setValue(true); 125 | } 126 | }; 127 | 128 | 129 | RouteRegistry.addItem(routeEntry); 130 | 131 | activeRoute 132 | .map(route => route === routeEntry) 133 | .listen((isActive, wasActive) => { 134 | if (!isActive && wasActive) { 135 | active.setValue(false); 136 | options.events?.onInactive?.(); 137 | } 138 | }); 139 | 140 | search.listen(change => { 141 | if (!active.getValue()) 142 | return; 143 | const url = new URL(location.href); 144 | 145 | Object.entries(change) 146 | .forEach(([ key, value ]) => { 147 | url.searchParams.set(key, value!.toString()); 148 | }); 149 | 150 | if (new URL(location.href).toString() !== url.toString()) 151 | navigation.navigate(url.toString()); 152 | }); 153 | 154 | groups.listen(change => { 155 | if (!active.getValue()) 156 | return; 157 | 158 | const filledRoute = new URL(createURLFromGroups(cleanedUpPath, change), location.origin); 159 | 160 | Object.entries(search) 161 | .forEach(([ key, value ]) => { 162 | if (value != undefined) 163 | filledRoute.searchParams.set(key, `${value}`); 164 | }); 165 | 166 | if (new URL(location.href).toString() !== filledRoute.toString()) 167 | navigation.navigate(filledRoute.toString()); 168 | }); 169 | 170 | return { 171 | entry: routeEntry, 172 | active, 173 | groups: groups.value as unknown, 174 | search: search.value as unknown, 175 | navigate: (groups, options) => { 176 | const filledRoute = createURLFromGroups(cleanedUpPath, groups); 177 | return navigation.navigate(filledRoute, options); 178 | }, 179 | createRoute: (newOptions) => 180 | createRoute({ 181 | ...newOptions, 182 | path: cleanedUpPath + newOptions.path.replace(/\/$/, "") as Path, 183 | }), 184 | } as Route; 185 | } 186 | 187 | 188 | function createURLFromGroups(cleanedUpPath: string, groups: EmptyObject) { 189 | return cleanedUpPath.split("/").map(x => x.startsWith(":") ? groups[ x.replace(/^:/, "") as keyof typeof groups ] : x).join("/"); 190 | } 191 | 192 | export function StartRouting() { 193 | const url = location.href; 194 | const route = getBestRouteFromUrl(url); 195 | activeRouteUrl.setValue(url); 196 | if (!route) return; 197 | route?.intercept(route.pattern.exec(url)!); 198 | } -------------------------------------------------------------------------------- /navigation/mod.ts: -------------------------------------------------------------------------------- 1 | export * from "./Menu.ts"; 2 | export * from "./Navigation.ts"; 3 | export * from "./Page.ts"; 4 | export * from "./Route.ts"; 5 | -------------------------------------------------------------------------------- /src/components/Image.css: -------------------------------------------------------------------------------- 1 | .wimage { 2 | position: relative; 3 | overflow: hidden; 4 | border-radius: 0.3rem; 5 | } 6 | 7 | .wimage img { 8 | display: block; 9 | width: 100%; 10 | } 11 | 12 | .wimage .loading-wheel { 13 | z-index: -1; 14 | } 15 | 16 | .wimage.loading { 17 | background-color: rgba(98, 98, 98, 0.2); 18 | } 19 | 20 | .wimage .dark-layer, 21 | .wimage .overlay, 22 | .wimage .progress, 23 | .wimage .background { 24 | position: absolute; 25 | } 26 | 27 | .wimage .dark-layer, 28 | .wimage .overlay, 29 | .wimage .progress { 30 | top: 0; 31 | bottom: 0; 32 | left: 0; 33 | right: 0; 34 | text-align: center; 35 | } 36 | 37 | .wimage .dark-layer { 38 | background-color: rgb(0 0 0 / 50%); 39 | } 40 | 41 | .wimage .overlay { 42 | mix-blend-mode: difference; 43 | font-weight: 900; 44 | padding: 0 1rem; 45 | } 46 | 47 | .wimage .overlay .small { 48 | font-size: 0.7rem; 49 | min-height: 13px; 50 | } 51 | 52 | .wimage .overlay .mid { 53 | font-size: 1.0625rem; 54 | } 55 | 56 | .wimage .overlay .big { 57 | font-size: 2.3rem; 58 | } 59 | 60 | .wimage .background { 61 | filter: grayscale(1); 62 | } 63 | 64 | .wimage .progress { 65 | overflow: hidden; 66 | } 67 | 68 | .wimage.resize-to-box img { 69 | object-fit: cover; 70 | height: 100%; 71 | } -------------------------------------------------------------------------------- /src/components/Image.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "../Component.ts"; 2 | import { createElement } from "../Components.ts"; 3 | import { Box } from "./Box.ts"; 4 | import { Custom } from "./Custom.ts"; 5 | import './Image.css'; 6 | import { Label } from "./Label.ts"; 7 | import { Spacer, Vertical } from "./Stacks.ts"; 8 | import { loadingWheel } from "./light-components/loadingWheel.ts"; 9 | export type AdvancedImage = 10 | | { type: "direct", source: () => Promise; } 11 | | { type: "loading"; } 12 | | { type: "uploading"; percentage: number; filename: string; text?: string; blobUrl: string; } 13 | | { type: "waiting-upload"; filename: string; text?: string; blobUrl: string; } 14 | ; 15 | 16 | export class ImageComponent extends Component { 17 | alt: string; 18 | 19 | constructor(data: string | AdvancedImage, alt: string) { 20 | super(); 21 | this.alt = alt; 22 | this.wrapper.classList.add("wimage"); 23 | if (typeof data == "string") { 24 | this.wrapper.classList.add("loading"); 25 | this.wrapper.append(this.renderLoading()); 26 | this.wrapper.append(this.renderImage(data, true)); 27 | } 28 | else if (data.type == "direct") { 29 | this.wrapper.classList.add("loading"); 30 | this.wrapper.append(this.renderLoading()); 31 | data.source().then(x => { 32 | this.wrapper.classList.remove("loading"); 33 | this.wrapper.children[ 0 ].replaceWith(this.renderImage(URL.createObjectURL(x), true)); 34 | }); 35 | } else if (data.type == "loading") { 36 | this.wrapper.classList.add("loading"); 37 | this.wrapper.append(this.renderLoading()); 38 | } else if (data.type == "uploading") { 39 | const background = this.renderImage(data.blobUrl); 40 | background.classList.add("background"); 41 | const progress = Box(Custom(this.renderImage(data.blobUrl))).draw(); 42 | progress.classList.add("progress"); 43 | progress.style.height = `${data.percentage.toFixed(1)}%`; 44 | const overlay = Vertical( 45 | Spacer(), 46 | Label(data.text ?? "Uploading...").addClass("small"), 47 | Spacer(), 48 | Label(`${data.percentage.toFixed(1)}%`).addClass("big"), 49 | Spacer(), 50 | Label(data.filename).addClass("small"), 51 | Spacer() 52 | ).addClass("overlay").draw(); 53 | const darkLayer = Box().addClass("dark-layer").draw(); 54 | this.wrapper.append(background, progress, darkLayer, overlay); 55 | } else if (data.type == "waiting-upload") { 56 | const background = this.renderImage(data.blobUrl); 57 | background.classList.add("background"); 58 | const progress = Box(Custom(this.renderImage(data.blobUrl))).draw(); 59 | progress.classList.add("progress"); 60 | progress.style.height = `100%`; 61 | const overlay = Vertical( 62 | Spacer(), 63 | Label(" ").addClass("small"), 64 | Spacer(), 65 | Label(data.text ?? "Finishing Upload...").addClass("mid"), 66 | Spacer(), 67 | Label(data.filename).addClass("small"), 68 | Spacer() 69 | ).addClass("overlay").draw(); 70 | const darkLayer = Box().addClass("dark-layer").draw(); 71 | this.wrapper.append(background, progress, darkLayer, overlay); 72 | } 73 | } 74 | private renderLoading() { 75 | return loadingWheel() as Element as HTMLElement; 76 | } 77 | private renderImage(source: string, removeLoading = false) { 78 | const img = createElement("img"); 79 | img.style.width = "100%"; 80 | img.src = source; 81 | img.alt = this.alt; 82 | if (removeLoading) 83 | img.onload = () => { 84 | this.wrapper.classList.remove("loading"); 85 | this.wrapper.querySelector(".loading-wheel")?.remove(); 86 | }; 87 | return img; 88 | } 89 | 90 | resizeToBox() { 91 | this.addClass("resize-to-box"); 92 | return this; 93 | } 94 | 95 | setAspectRatio(ratio: string) { 96 | this.wrapper.style.aspectRatio = ratio; 97 | return this; 98 | } 99 | } 100 | 101 | export const Image = (data: string | AdvancedImage, alt: string) => new ImageComponent(data, alt); 102 | -------------------------------------------------------------------------------- /src/components/Switch.css: -------------------------------------------------------------------------------- 1 | .wswitch { 2 | display: grid; 3 | --ball-size: 21px; 4 | grid-template-columns: calc(var(--ball-size)); 5 | transition: grid-template-columns 250ms ease, box-shadow 250ms var(--jump-ani); 6 | justify-items: end; 7 | width: 74px; 8 | height: 37px; 9 | padding: 8px; 10 | border-radius: 45px; 11 | background: hsla(var(--background-color-h), var(--background-color-s), var(--background-color-l), 100%); 12 | cursor: pointer; 13 | box-sizing: border-box; 14 | user-select: none; 15 | box-shadow: 0px 0px 0px 0px hsla(var(--background-color-h), var(--background-color-s), var(--background-color-l), 25%); 16 | 17 | } 18 | 19 | .wswitch:not(.loading):not(.disabled):active { 20 | transform: translate(0, 3%); 21 | } 22 | 23 | .wswitch:focus { 24 | outline: none; 25 | } 26 | 27 | .wswitch.selected { 28 | grid-template-columns: 100%; 29 | } 30 | 31 | .wswitch:not(.disabled):not(.loading):is(:hover, :focus) { 32 | box-shadow: 0px 0px 0px 5px hsla(var(--background-color-h), var(--background-color-s), var(--background-color-l), 25%); 33 | } 34 | 35 | .wswitch.loading { 36 | cursor: default; 37 | --ball-size: 33px; 38 | } 39 | 40 | .wswitch .check-icon { 41 | display: block; 42 | font-size: 1rem; 43 | } 44 | 45 | .wswitch.selected:not(.loading) .check-icon { 46 | animation: fadeIn 200ms; 47 | } 48 | 49 | @keyframes fadeInLoadingWheel { 50 | 0% { 51 | opacity: 0%; 52 | } 53 | 54 | 50% { 55 | opacity: 0%; 56 | } 57 | 58 | 100% { 59 | opacity: 100%; 60 | } 61 | } 62 | 63 | @keyframes fadeIn { 64 | 0% { 65 | opacity: 0%; 66 | } 67 | } 68 | 69 | .wswitch:not(.loading) .load-element .loading-wheel { 70 | display: none; 71 | } 72 | 73 | .wswitch .load-element .loading-wheel { 74 | width: 32px; 75 | } 76 | 77 | .wswitch .load-element { 78 | position: absolute; 79 | } 80 | 81 | .wswitch.loading .load-element { 82 | animation: fadeInLoadingWheel 450ms; 83 | } 84 | 85 | .wswitch>div { 86 | background-color: var(--font-color); 87 | width: var(--ball-size); 88 | height: 100%; 89 | border-radius: 100px; 90 | position: relative; 91 | display: grid; 92 | place-items: center; 93 | transition: 94 | width 250ms ease; 95 | } 96 | 97 | 98 | .wswitch:is(.unselected, .selected.loading) .check-icon { 99 | display: none; 100 | } 101 | 102 | .wswitch.grayscaled { 103 | --background-color-h: var(--color-grayscaled-hue); 104 | --background-color-s: var(--color-grayscaled-saturation); 105 | --background-color-l: var(--color-grayscaled-lightness); 106 | --font-color: var(--color-grayscaled-font); 107 | } 108 | 109 | .wswitch.colored { 110 | --background-color-h: var(--color-colored-hue); 111 | --background-color-s: var(--color-colored-saturation); 112 | --background-color-l: var(--color-colored-lightness); 113 | --font-color: var(--color-colored-font); 114 | } 115 | 116 | .wswitch.critical { 117 | --background-color-h: var(--color-critical-hue); 118 | --background-color-s: var(--color-critical-saturation); 119 | --background-color-l: var(--color-critical-lightness); 120 | --font-color: var(--color-critical-font); 121 | } 122 | 123 | .wswitch.disabled { 124 | cursor: default; 125 | --background-color-h: var(--color-disabled-hue); 126 | --background-color-s: var(--color-disabled-saturation); 127 | --background-color-l: var(--color-disabled-lightness); 128 | --font-color: var(--color-disabled-font); 129 | } -------------------------------------------------------------------------------- /src/components/Switch.ts: -------------------------------------------------------------------------------- 1 | import { accessibilityDisableTabOnDisabled } from "../Accessibility.ts"; 2 | import { Color } from "../Color.ts"; 3 | import { Refable, Reference, asRef } from "../State.ts"; 4 | import { MIcon } from "../icons/MaterialIcons.ts"; 5 | import { ButtonStyle, ColoredComponent } from "../types.ts"; 6 | import { Box } from "./Box.ts"; 7 | import { Custom } from "./Custom.ts"; 8 | import "./Switch.css"; 9 | import { loadingWheel } from "./light-components/loadingWheel.ts"; 10 | 11 | class SwitchComponent extends ColoredComponent { 12 | loading = asRef(false); 13 | selected: Reference; 14 | constructor(selected: Reference, icon = MIcon("check")) { 15 | super(); 16 | this.selected = selected; 17 | this.wrapper.tabIndex = accessibilityDisableTabOnDisabled(); 18 | this.addClass(selected.map(it => it ? "selected" : "unselected"), "wswitch", Color.Grayscaled); 19 | this.addClass(this.loading.map(it => it ? 'loading' : 'non-loading')); 20 | this.wrapper.append(Box( 21 | icon.addClass("check-icon"), 22 | Box(Custom(loadingWheel() as Element as HTMLElement)).addClass("load-element", "loading") 23 | ) 24 | .draw() 25 | ); 26 | } 27 | onClick(action: (me: MouseEvent, pointer: Reference) => void) { 28 | this.wrapper.addEventListener('click', (me) => { 29 | if (this.color.getValue() == Color.Disabled) return; 30 | action(me, this.selected); 31 | }); 32 | return this; 33 | } 34 | 35 | onPromiseClick(action: (me: MouseEvent, pointer: Reference) => Promise) { 36 | this.onClick((me, p) => { 37 | if (this.loading.getValue()) return; 38 | this.loading.setValue(true); 39 | action(me, p) 40 | .finally(() => { 41 | this.loading.setValue(false); 42 | }); 43 | }); 44 | return this; 45 | } 46 | 47 | setStyle(_style: Refable): this { 48 | throw new Error("Method not implemented."); 49 | } 50 | } 51 | 52 | export const Switch = (selected: Reference) => new SwitchComponent(selected); 53 | -------------------------------------------------------------------------------- /src/components/Tab.css: -------------------------------------------------------------------------------- 1 | .wtab { 2 | height: 37px; 3 | display: inline-flex; 4 | align-items: center; 5 | font-weight: 630; 6 | font-size: 13.8px; 7 | letter-spacing: .02rem; 8 | border-radius: 5px; 9 | cursor: pointer; 10 | position: relative; 11 | user-select: none; 12 | text-decoration: none; 13 | transition: transform 100ms ease; 14 | z-index: 1; 15 | color: hsl(var(--background-color-h), var(--background-color-s), var(--background-color-l)); 16 | background: hsla(var(--background-color-h), var(--background-color-s), var(--background-color-l), 25%); 17 | transition: background-color 200ms ease; 18 | overflow: hidden; 19 | white-space: nowrap; 20 | } 21 | 22 | .wtab:focus { 23 | outline: none; 24 | background: hsla(var(--background-color-h), var(--background-color-s), var(--background-color-l), 30%); 25 | } 26 | 27 | .wtab .item { 28 | height: 33px; 29 | margin: 0 2px; 30 | padding: 0 6px; 31 | border-radius: 3px; 32 | display: flex; 33 | align-items: center; 34 | transition: background 250ms ease; 35 | } 36 | 37 | .wtab .item:not(.active):is(:hover, .hover) { 38 | background: hsla(var(--background-color-h), var(--background-color-s), var(--background-color-l), 20%); 39 | } 40 | 41 | .wtab .active { 42 | background: hsl(var(--background-color-h), var(--background-color-s), var(--background-color-l)); 43 | color: var(--font-color); 44 | } 45 | 46 | .wtab.grayscaled { 47 | --background-color-h: var(--color-grayscaled-hue); 48 | --background-color-s: var(--color-grayscaled-saturation); 49 | --background-color-l: var(--color-grayscaled-lightness); 50 | --font-color: var(--color-grayscaled-font); 51 | } 52 | 53 | .wtab.colored { 54 | --background-color-h: var(--color-colored-hue); 55 | --background-color-s: var(--color-colored-saturation); 56 | --background-color-l: var(--color-colored-lightness); 57 | --font-color: var(--color-colored-font); 58 | } 59 | 60 | .wtab.critical { 61 | --background-color-h: var(--color-critical-hue); 62 | --background-color-s: var(--color-critical-saturation); 63 | --background-color-l: var(--color-critical-lightness); 64 | --font-color: var(--color-critical-font); 65 | } 66 | 67 | .wtab.disabled { 68 | cursor: default; 69 | --background-color-h: var(--color-disabled-hue); 70 | --background-color-s: var(--color-disabled-saturation); 71 | --background-color-l: var(--color-disabled-lightness); 72 | --font-color: var(--color-disabled-font); 73 | } -------------------------------------------------------------------------------- /src/components/Tab.ts: -------------------------------------------------------------------------------- 1 | import { accessibilityDisableTabOnDisabled } from "../Accessibility.ts"; 2 | import { Color } from "../Color.ts"; 3 | import { Component } from "../Component.ts"; 4 | import { createElement } from "../Components.ts"; 5 | import { Custom } from "./Custom.ts"; 6 | import './Tab.css'; 7 | 8 | /** 9 | * @deprecated Options to be replaced with build style 10 | */ 11 | export const Tab = ({ color, selectedIndex, selectedOn }: { 12 | color?: Color, 13 | selectedIndex?: number, 14 | selectedOn?: (index: number) => void, 15 | }, ...dropdown: ([ displayName: string, action: () => void ])[]): Component => { 16 | const tabbar = createElement("div") as HTMLDivElement; 17 | tabbar.classList.add("wtab", color ?? Color.Grayscaled); 18 | tabbar.tabIndex = accessibilityDisableTabOnDisabled(color); 19 | let focusSelectedIndex = 0; 20 | tabbar.onkeydown = ({ key }) => { 21 | if (key == "Enter") { 22 | getItems(tabbar)[ focusSelectedIndex ].click(); 23 | return; 24 | } 25 | focusSelectedIndex += (key == "ArrowRight" ? 1 : -1); 26 | if (focusSelectedIndex == -1) focusSelectedIndex = dropdown.length - 1; 27 | if (focusSelectedIndex == dropdown.length) focusSelectedIndex = 0; 28 | tabbar.onblur?.(null as unknown as FocusEvent); 29 | getItems(tabbar)[ focusSelectedIndex ].classList.add("hover"); 30 | }; 31 | tabbar.onfocus = () => { 32 | focusSelectedIndex = 0; 33 | getItems(tabbar)[ focusSelectedIndex ].classList.add("hover"); 34 | }; 35 | tabbar.onblur = () => getItems(tabbar).forEach(x => x.classList.remove("hover")); 36 | 37 | dropdown.forEach(([ displayName, action ], index) => { 38 | const item = createElement("div"); 39 | item.classList.add('item'); 40 | item.onclick = () => { action(); selectedOn?.(index); }; 41 | item.innerText = displayName; 42 | tabbar.append(item); 43 | if (selectedIndex == index) item.classList.add("active"); 44 | }); 45 | 46 | return Custom(tabbar); 47 | }; 48 | 49 | function getItems(tabbar: HTMLDivElement): NodeListOf { 50 | return tabbar.querySelectorAll("div.item"); 51 | } 52 | -------------------------------------------------------------------------------- /src/components/Taglist.ts: -------------------------------------------------------------------------------- 1 | import { Color } from "../Color.ts"; 2 | import { Component } from "../Component.ts"; 3 | import { createElement } from "../Components.ts"; 4 | import { Refable, Reference, asState } from "../State.ts"; 5 | import { MIcon } from "../icons/MaterialIcons.ts"; 6 | import { ButtonStyle } from "../types.ts"; 7 | import { Button } from "./Button.ts"; 8 | import { IconButton } from "./IconButton.ts"; 9 | import './taglist.css'; 10 | 11 | export const Taglist = (list: Refable[], selected: Reference, icon = { forward: MIcon("arrow_back_ios_new"), backwards: MIcon("arrow_forward_ios") }) => new class extends Component { 12 | items = createElement("div"); 13 | move = createElement("div"); 14 | 15 | constructor() { 16 | super(); 17 | const state = asState({ 18 | left: false, 19 | right: false 20 | }); 21 | this.wrapper.classList.add("wtags"); 22 | this.items.classList.add("items"); 23 | this.move.classList.add("move"); 24 | 25 | // Construct layout 26 | this.wrapper.append(this.items, this.move); 27 | this.items.append(...list.map((x, i) => 28 | Button(x) 29 | .setColor(Color.Colored) 30 | .onClick(() => selected.setValue(i)) 31 | .setStyle(selected.map(index => index == i ? ButtonStyle.Normal : ButtonStyle.Secondary)) 32 | .draw() 33 | )); 34 | 35 | this.move.append( 36 | IconButton(icon.forward, "go backwards in tag list") 37 | .onClick(() => this.items.scrollBy({ 38 | left: 0 - this.wrapper.clientWidth / 2, 39 | behavior: "smooth" 40 | })) 41 | .addClass(state.$left.map(val => val ? "show" : "hidden")) 42 | .draw(), 43 | IconButton(icon.backwards, "go forward in tag list") 44 | .addClass(state.$right.map(val => val ? "show" : "hidden")) 45 | .onClick(() => this.items.scrollBy({ 46 | left: this.wrapper.clientWidth / 2, 47 | behavior: "smooth" 48 | })) 49 | .draw(), 50 | ); 51 | 52 | this.items.addEventListener("scroll", () => { 53 | if (this.items.scrollLeft == 0) 54 | state.left = false; 55 | else 56 | state.left = true; 57 | 58 | if (this.items.scrollWidth - this.items.clientWidth - this.items.scrollLeft == 0) 59 | state.right = false; 60 | else 61 | state.right = true; 62 | 63 | }, { passive: true }); 64 | 65 | new ResizeObserver(() => { 66 | state.right = this.items.scrollWidth !== this.items.offsetWidth; 67 | }).observe(this.wrapper); 68 | // Because sometimes font rendering can get in out way 69 | setTimeout(() => { 70 | state.right = this.items.scrollWidth !== this.items.offsetWidth; 71 | }, 1000); 72 | } 73 | }; 74 | -------------------------------------------------------------------------------- /src/components/taglist.css: -------------------------------------------------------------------------------- 1 | .wtags { 2 | display: grid; 3 | position: relative; 4 | width: 100%; 5 | } 6 | 7 | .wtags .items { 8 | display: grid; 9 | grid-auto-flow: column; 10 | gap: calc(var(--gap) / 2); 11 | overflow: auto; 12 | padding: 5px; 13 | margin: -5px; 14 | justify-content: start; 15 | } 16 | 17 | .wtags .wbutton { 18 | border-radius: 100rem; 19 | } 20 | 21 | .wtags .move .wiconbutton { 22 | position: absolute; 23 | top: 0; 24 | background-color: transparent; 25 | color: var(--layer-color); 26 | margin: -5px; 27 | padding: 5px; 28 | border-radius: 0; 29 | --outside-color: var(--layer-background); 30 | transition: transfrom 250ms var(--jump-ani); 31 | } 32 | 33 | .wtags .move .wiconbutton.hidden { 34 | display: none; 35 | } 36 | 37 | .wtags .move .wiconbutton:hover { 38 | box-shadow: none; 39 | } 40 | 41 | .wtags .move .wiconbutton:hover span { 42 | transform: translate(var(--offset, 0)); 43 | } 44 | 45 | .wtags .move .wiconbutton:nth-child(1) { 46 | left: 0; 47 | --offset: -2px; 48 | background: linear-gradient(89.33deg, var(--outside-color) 0.58%, rgba(0, 16, 27, 0) 99.44%) 49 | } 50 | 51 | .wtags .move .wiconbutton:nth-child(2) { 52 | right: 0; 53 | --offset: 2px; 54 | background: linear-gradient(270deg, var(--outside-color) 0.01%, rgba(0, 16, 27, 0) 100%); 55 | } --------------------------------------------------------------------------------