├── .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 |
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