From a9971a1a12cce1fa137ee4ae524ca1edf0ccb363 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Sun, 3 Mar 2024 21:35:04 +0000 Subject: [PATCH] deploy --- docs/CNAME | 1 + docs/examples/freiluftkino/actions.html | 53 + docs/examples/freiluftkino/details.html | 156 ++ docs/examples/freiluftkino/icon.png | Bin 0 -> 2754 bytes docs/examples/freiluftkino/index.html | 47 + docs/examples/freiluftkino/list.html | 152 ++ .../freiluftkino/manifest.webmanifest | 14 + docs/examples/freiluftkino/module.css | 27 + docs/examples/freiluftkino/nav.html | 91 + docs/examples/frlkino/assets/logo.svg | 8 + docs/examples/frlkino/assets/manifest.json | 15 + docs/examples/frlkino/assets/style.css | 151 ++ docs/examples/frlkino/filters.mjs | 188 ++ docs/examples/frlkino/index.html | 10 + docs/examples/frlkino/index.mjs | 127 ++ docs/examples/frlkino/menu.mjs | 69 + docs/examples/frlkino/show.mjs | 229 +++ docs/examples/hackernews/hackernews.css | 151 ++ docs/examples/hackernews/index.html | 24 + docs/examples/hackernews/manifest.json | 5 + docs/examples/hackernews/stories.html | 109 ++ docs/examples/hackernews/story.html | 100 + docs/examples/style/index.html | 97 + docs/examples/style/style.css | 251 +++ docs/examples/todomvc/index.html | 139 ++ docs/examples/todomvc/todomvc.css | 185 ++ docs/freiluftkino/actions.html | 53 + docs/freiluftkino/details.html | 156 ++ docs/freiluftkino/icon.png | Bin 0 -> 2754 bytes docs/freiluftkino/index.html | 1619 +++++++++++++++++ docs/freiluftkino/list.html | 152 ++ docs/freiluftkino/manifest.webmanifest | 14 + docs/freiluftkino/module.css | 27 + docs/freiluftkino/nav.html | 91 + docs/frlkino/assets/logo.svg | 8 + docs/frlkino/assets/manifest.json | 15 + docs/frlkino/assets/style.css | 151 ++ docs/frlkino/filters.mjs | 188 ++ docs/frlkino/index.html | 16 + docs/frlkino/index.mjs | 127 ++ docs/frlkino/menu.mjs | 69 + docs/frlkino/show.mjs | 229 +++ docs/hackernews/hackernews.css | 151 ++ docs/hackernews/index.html | 738 ++++++++ docs/hackernews/manifest.json | 5 + docs/hackernews/stories.html | 109 ++ docs/hackernews/story.html | 100 + docs/index.html | 36 + docs/modules/app/index.html | 195 ++ docs/modules/card/card.css | 53 + docs/modules/card/index.html | 39 + docs/modules/chart/index.html | 122 ++ docs/modules/icon/index.html | 152 ++ docs/src/base.css | 58 + docs/src/bundler.mjs | 71 + docs/src/compiler.mjs | 243 +++ docs/src/dev.mjs | 48 + docs/src/jsxy.mjs | 372 ++++ docs/src/parser.mjs | 101 + docs/src/runtime.mjs | 166 ++ docs/src/test.mjs | 306 ++++ docs/src/util.mjs | 57 + docs/style/index.html | 101 + docs/style/style.css | 251 +++ docs/todomvc/index.html | 473 +++++ docs/todomvc/todomvc.css | 185 ++ 66 files changed, 9446 insertions(+) create mode 100644 docs/CNAME create mode 100644 docs/examples/freiluftkino/actions.html create mode 100644 docs/examples/freiluftkino/details.html create mode 100644 docs/examples/freiluftkino/icon.png create mode 100644 docs/examples/freiluftkino/index.html create mode 100644 docs/examples/freiluftkino/list.html create mode 100644 docs/examples/freiluftkino/manifest.webmanifest create mode 100644 docs/examples/freiluftkino/module.css create mode 100644 docs/examples/freiluftkino/nav.html create mode 100644 docs/examples/frlkino/assets/logo.svg create mode 100644 docs/examples/frlkino/assets/manifest.json create mode 100644 docs/examples/frlkino/assets/style.css create mode 100644 docs/examples/frlkino/filters.mjs create mode 100644 docs/examples/frlkino/index.html create mode 100644 docs/examples/frlkino/index.mjs create mode 100644 docs/examples/frlkino/menu.mjs create mode 100644 docs/examples/frlkino/show.mjs create mode 100644 docs/examples/hackernews/hackernews.css create mode 100644 docs/examples/hackernews/index.html create mode 100644 docs/examples/hackernews/manifest.json create mode 100644 docs/examples/hackernews/stories.html create mode 100644 docs/examples/hackernews/story.html create mode 100644 docs/examples/style/index.html create mode 100644 docs/examples/style/style.css create mode 100644 docs/examples/todomvc/index.html create mode 100644 docs/examples/todomvc/todomvc.css create mode 100644 docs/freiluftkino/actions.html create mode 100644 docs/freiluftkino/details.html create mode 100644 docs/freiluftkino/icon.png create mode 100644 docs/freiluftkino/index.html create mode 100644 docs/freiluftkino/list.html create mode 100644 docs/freiluftkino/manifest.webmanifest create mode 100644 docs/freiluftkino/module.css create mode 100644 docs/freiluftkino/nav.html create mode 100644 docs/frlkino/assets/logo.svg create mode 100644 docs/frlkino/assets/manifest.json create mode 100644 docs/frlkino/assets/style.css create mode 100644 docs/frlkino/filters.mjs create mode 100644 docs/frlkino/index.html create mode 100644 docs/frlkino/index.mjs create mode 100644 docs/frlkino/menu.mjs create mode 100644 docs/frlkino/show.mjs create mode 100644 docs/hackernews/hackernews.css create mode 100644 docs/hackernews/index.html create mode 100644 docs/hackernews/manifest.json create mode 100644 docs/hackernews/stories.html create mode 100644 docs/hackernews/story.html create mode 100644 docs/index.html create mode 100644 docs/modules/app/index.html create mode 100644 docs/modules/card/card.css create mode 100644 docs/modules/card/index.html create mode 100644 docs/modules/chart/index.html create mode 100644 docs/modules/icon/index.html create mode 100644 docs/src/base.css create mode 100644 docs/src/bundler.mjs create mode 100644 docs/src/compiler.mjs create mode 100644 docs/src/dev.mjs create mode 100644 docs/src/jsxy.mjs create mode 100644 docs/src/parser.mjs create mode 100644 docs/src/runtime.mjs create mode 100644 docs/src/test.mjs create mode 100644 docs/src/util.mjs create mode 100644 docs/style/index.html create mode 100644 docs/style/style.css create mode 100644 docs/todomvc/index.html create mode 100644 docs/todomvc/todomvc.css diff --git a/docs/CNAME b/docs/CNAME new file mode 100644 index 0000000..b057c57 --- /dev/null +++ b/docs/CNAME @@ -0,0 +1 @@ +www.guckschnell.de diff --git a/docs/examples/freiluftkino/actions.html b/docs/examples/freiluftkino/actions.html new file mode 100644 index 0000000..3c01aa0 --- /dev/null +++ b/docs/examples/freiluftkino/actions.html @@ -0,0 +1,53 @@ + + + + actions + + + + + + + diff --git a/docs/examples/freiluftkino/details.html b/docs/examples/freiluftkino/details.html new file mode 100644 index 0000000..d04ec23 --- /dev/null +++ b/docs/examples/freiluftkino/details.html @@ -0,0 +1,156 @@ + + + + details + + + + + + + + + diff --git a/docs/examples/freiluftkino/icon.png b/docs/examples/freiluftkino/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c605b0a7b126c7d2451ad08e8b862b72ea36b49e GIT binary patch literal 2754 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzEX7WqAsj$Z!;#X#z`#}M z>EaktG3V{ggMrL}Jgko0xBdi+aTXkGeSX`!oaOxrO4;1FP7P*5H$fZC!Hg@#IWfU-J6g}t%ya&w07PX-{XlUUq=-TUL_nKQt$ss1ec z;49$4bxzE3c7_AXXV0|U%lII80Lm|5jWP-bFa!>~t!0+^DzxfV-Qy!5XL!2$xvX + + + freiluftkino + + + + + + + + + + + + diff --git a/docs/examples/freiluftkino/list.html b/docs/examples/freiluftkino/list.html new file mode 100644 index 0000000..ea2277c --- /dev/null +++ b/docs/examples/freiluftkino/list.html @@ -0,0 +1,152 @@ + + + + list + + + + + + + + + + diff --git a/docs/examples/freiluftkino/manifest.webmanifest b/docs/examples/freiluftkino/manifest.webmanifest new file mode 100644 index 0000000..e1b42ca --- /dev/null +++ b/docs/examples/freiluftkino/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "version": "0.0.2", + "display": "standalone", + "name": "freiluftkino", + "short_name": "flk", + "background_color": "#000", + "theme_color": "#000", + "icons": [ + { + "src": "icon.png", + "sizes": "any" + } + ] +} diff --git a/docs/examples/freiluftkino/module.css b/docs/examples/freiluftkino/module.css new file mode 100644 index 0000000..ecfb846 --- /dev/null +++ b/docs/examples/freiluftkino/module.css @@ -0,0 +1,27 @@ +html { + color: #eee; + background-color: #000; +} + +img { + height: 100%; + object-fit: cover; +} + +a { + color: inherit; + text-decoration: none; +} + +.button { + border: 0.1em solid #fff; + display: inline-block; + line-height: 2em; + font-weight: bold; + padding: 0.25em 0.5em; + border-radius: 0.25em; +} + +x-icon-heart.favorited svg{ + fill: red; +} diff --git a/docs/examples/freiluftkino/nav.html b/docs/examples/freiluftkino/nav.html new file mode 100644 index 0000000..39a73b6 --- /dev/null +++ b/docs/examples/freiluftkino/nav.html @@ -0,0 +1,91 @@ + + + + nav + + + + + + + diff --git a/docs/examples/frlkino/assets/logo.svg b/docs/examples/frlkino/assets/logo.svg new file mode 100644 index 0000000..01c5e50 --- /dev/null +++ b/docs/examples/frlkino/assets/logo.svg @@ -0,0 +1,8 @@ + + + + FRL + KNO + diff --git a/docs/examples/frlkino/assets/manifest.json b/docs/examples/frlkino/assets/manifest.json new file mode 100644 index 0000000..6206b97 --- /dev/null +++ b/docs/examples/frlkino/assets/manifest.json @@ -0,0 +1,15 @@ +{ + "version": "0.0.1", + "display": "standalone", + "name": "freiluftkino", + "short_name": "frlkino", + "background_color": "white", + "theme_color": "white", + "start_url": "../", + "icons": [ + { + "src": "logo.svg", + "sizes": "any" + } + ] +} diff --git a/docs/examples/frlkino/assets/style.css b/docs/examples/frlkino/assets/style.css new file mode 100644 index 0000000..2c49a25 --- /dev/null +++ b/docs/examples/frlkino/assets/style.css @@ -0,0 +1,151 @@ +:root { + --bg: #fff; + --mg: #666; + --lg: #bbb; + --fg: #000; + --hl: hsla(95, 50%, 70%, 50%); + --ll: #eee; + + --hf: sans-serif; + --bf: system-ui; + + --bw: .1rem; +} + +*, ::after, ::before { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + font: normal 1rem/1.6 var(--bf); + background: var(--bg); + color: var(--fg); + min-height: 100%; +} + +img { + display: block; + max-width: 100%; + height: auto; +} + +.full-width { + width: 100vw !important; + max-width: 100vw !important; + margin-left: calc(50% - 50vw) !important; + margin-right: 0 !important; +} + + +body, h1, h2, h3 { + margin: 0; +} + +button, input, textarea, select { + font: inherit; + color: inherit; + background: inherit; +} + +x-app nav { + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: white; + font-size: 2em; + z-index: 1; +} + +x-app nav .logo { + width: 1em; + height: 1em; + margin: .25em; +} + +x-shows .shows, .favs { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 33%; + gap: 1em; + overflow-x: auto; +} + +.favs-wrapper { + margin: 1em; + position: absolute; + bottom: 0; +} + +@media (min-width: 70ch) { + main { + max-width: 70ch; + margin: 0 auto; + } + x-shows .shows { + grid-auto-columns: 12.5%; + } +} + +x-shows .day { + margin-top: 1em; + margin-left: 1em; +} + +.show { + display: grid; + grid-template-rows: 1fr auto auto; + font-size: 1rem; + gap: .5em; + color: inherit; + text-decoration: none; +} + +.show img { + object-fit: cover; + height: 100%; + width: 100%; + aspect-ratio: 2/3; + border: none; +} + +img[src=""] { + background: black; +} + +.show .title { + line-height: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.show .cinema { + line-height: 1; + color: grey; + font-size: 0.75em; + margin-top: -0.5em; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +x-movies { + display: grid; + gap: 1em; + grid-template-columns: repeat(auto-fill, minmax(25%, 1fr)); + padding: 1em; +} + +.bookable { + position: relative; +} + +.hidden { + display: none; +} diff --git a/docs/examples/frlkino/filters.mjs b/docs/examples/frlkino/filters.mjs new file mode 100644 index 0000000..57f85bf --- /dev/null +++ b/docs/examples/frlkino/filters.mjs @@ -0,0 +1,188 @@ +import {html, css, render, query, useEffect, sub} from "../../src/jsxy.mjs"; + +css` + x-filters form { + display: flex; + position: relative; + background: var(--ll); + border-top: 1px solid var(--mg); + border-bottom: 1px solid var(--mg); + } + + x-filters .item > button { + position: relative; + padding: .5em 1em; + border: none; + border-right: 1px solid var(--mg); + background: var(--ll); + } + + x-filters .item > button.enabled::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: .125em; + background: var(--fg); + } + + x-filters .item.active > button { + background: var(--bg); + border-bottom: 1px solid var(--bg); + margin-bottom: -1px; + } + + x-filters .item:last-of-type.active > button { + border-right: 1px solid var(--mg); + } + + x-filters .dropdown { + display: none; + } + + x-filters .active .dropdown { + position: absolute; + display: block; + background: var(--bg); + top: calc(100% + 1px); + left: 0; + width: 100%; + border-bottom: 1px solid var(--fg); + } + + x-filters .active .cinemas { + display: flex; + gap: 1em; + flex-wrap: wrap; + padding: 4em 1em 1em 1em; + } + + x-filters .cinema input { + display: none; + } + + x-filters .cinemas .clear { + position: absolute; + top: 0; + right: 0; + margin: 1em; + border: none; + background: var(--fg); + color: var(--bg); + } + + x-filters .cinema label { + border: 1px solid var(--fg); + padding: .25em; + } + + x-filters .cinema input:checked + label { + color: var(--bg); + background: var(--fg); + } + + x-filters .active .search { + padding: 1em; + display: flex; + justify-content: stretch; + } + + x-filters .search input { + width: 100%; + border: 1px solid var(--fg); + padding: .25em; + } + + x-filters .search .clear { + border: 1px solid var(--fg); + border-left: none; + padding: 0 1em; + } + + x-filters .lang input { + display: none; + } + + x-filters .lang label { + display: block; + padding: .5em 1em; + border-right: 1px solid var(--mg); + } + + x-filters .lang input:checked + label { + background: var(--fg); + color: var(--bg); + } +`; + +export function Filters({$, cinemas}) { + useEffect(() => { + function updateButtons() { + const config = query.config || {}; + $.cinemasButton.classList.toggle("enabled", Object.keys(config.cinemas || {}).length); + $.searchButton.classList.toggle("enabled", !!config.searchTitle); + } + updateButtons(); + return sub(query, "config", updateButtons); + }); + + function toggle(e) { + const node = e.target.parentNode; + for (let el of $.filters.querySelectorAll(".active")) { + if (el !== node) el.classList.toggle("active", 0); + } + node.classList.toggle("active"); + } + + function onCinemaClear() { + query.config = {...query.config, cinemas: {}}; + render($); + } + + function onSearchClear() { + query.config = {...query.config, searchTitle: null}; + render($); + } + + const tags = cinemas.map(x => { + return html` +
+ + +
` + }); + + return html` + +
+
+ + +
+
+ + +
+
+ +
+ + ${tags} +
+
+
+ +
+ + +
+
+
+
+ `; +} diff --git a/docs/examples/frlkino/index.html b/docs/examples/frlkino/index.html new file mode 100644 index 0000000..4195557 --- /dev/null +++ b/docs/examples/frlkino/index.html @@ -0,0 +1,10 @@ + + + + freiluftkino + + + + + + diff --git a/docs/examples/frlkino/index.mjs b/docs/examples/frlkino/index.mjs new file mode 100644 index 0000000..34104b0 --- /dev/null +++ b/docs/examples/frlkino/index.mjs @@ -0,0 +1,127 @@ +import {html, css, route, render, query, db, useEffect, sub} from "../../src/jsxy.mjs"; +import {Menu} from "./menu.mjs"; +import {Show} from "./show.mjs"; +import {Filters} from "./filters.mjs"; + +const history = await fetch("https://niklasfasching.github.io/freiluftkino/history.json").then(r => r.json()); +const shows = await fetch("https://niklasfasching.github.io/freiluftkino/shows.json").then(r => r.json()); +const showsByDate = groupShowsBy(shows, "date"); +const showsByMovie = groupShowsBy(shows, "normalizedTitle"); +const cinemas = [...new Set(Object.values(shows).map(x => x.cinemaShortName))].sort(); + +route({ + "/": wrap(ByDate, true), + "/movies": wrap(ByMovie, true), + "/show/{id}": wrap(Show), +}, document.body); + +function wrap(tag, showFilters) { + return function(props) { + function onClose() { + if (route.path === "/") render(props.$.main) + } + return html` + <${Nav} key=nav ...=${{shows, showsByMovie, onClose}}/> + ${showFilters && html`<${Filters} key=filters cinemas=${cinemas} refs=${props.$}/>`} +
+ <${tag} key=${tag.name} ...=${{history, shows, cinemas, showsByDate, showsByMovie}} ...=${props} $main/> +
+
` + } +} + +function Nav({$, onClose, shows, showsByMovie}) { + const links = { + "/": "By Date", + "/movies/": "By Movie", + }; + + useEffect(() => sub(db, "favs", () => render($))); + + const favs = Object.keys(db.favs || {}).map(normalizedTitle => { + const show = showsByMovie[normalizedTitle]?.filter(x => x.timestamp > Date.now())[0]; + if (!show) return; + return html` +
+ +
${show.title}
+
${show.cinemaShortName}
+
`; + }); + + return html` + `; +} + +function ByDate({$, showsByDate}) { + useEffect(() => sub(query, "config", () => render($))); + const days = Object.entries(showsByDate).map(([date, shows]) => { + shows = filterByConfig(shows) + if (!shows.length) return; + shows = shows.map(x => html` + + +
${x.title}
+
${x.time} | ${x.cinemaShortName}
+
+ `); + return html`
+

${date.split(",")[1]}

+
${shows}
+
` + }); + return html`${days.filter(Boolean)}`; +} + +function ByMovie({$, showsByMovie}) { + useEffect(() => sub(query, "config", () => render($))); + const movies = Object.entries(showsByMovie).map(([normalizedTitle, shows]) => { + shows = filterByConfig(shows) + if (!shows.length) return; + const show = shows[0]; + return html` + + +
${show.title}
+
${show.cinemaShortName}
+
+ `; + }); + return html`${movies}`; +} + +function filterByConfig(shows) { + const activeCinemas = query.config?.cinemas || {}; + if (Object.keys(activeCinemas).length) { + shows = shows.filter(x => activeCinemas[x.cinemaShortName]); + } + if (query.config?.searchTitle) { + shows = shows.filter(x => x.normalizedTitle.includes(query.config?.searchTitle.toUpperCase())) + } + if (query.config?.ov || query.config?.en) { + shows = shows.filter(x => { + return (query.config.ov && x.version.original) || (query.config.en && x.version.english); + }) + } + return shows +} + +function groupShowsBy(shows, key) { + return Object.values(shows) + .filter(x => x.timestamp >= Date.now()) + .sort((a, b) => a.timestamp - b.timestamp) + .reduce((m, x) => { + m[x[key]] = (m[x[key]] || []).concat(x).sort((x, y) => x.time > y.time); + return m; + }, {}); +} diff --git a/docs/examples/frlkino/menu.mjs b/docs/examples/frlkino/menu.mjs new file mode 100644 index 0000000..eda1338 --- /dev/null +++ b/docs/examples/frlkino/menu.mjs @@ -0,0 +1,69 @@ +import {html, css, route, useEffect} from "../../src/jsxy.mjs"; + +css` + x-menu button { + border: none; + padding: 0 .25em; + cursor: pointer; + } + + x-menu .overlay { + position: absolute; + top: 100%; + left: 0; + height: calc(100vh - 100%); + width: 0; + background: var(--bg); + padding-top: 2em; + transform: translateX(100vw); + transform-origin: top right; + overflow: hidden; + transition: transform .1s ease-in-out, transform .1s ease-in-out; + font-size: 1rem; + } + + x-menu .open + .overlay { + transform: translateX(0); + width: 100vw; + } + + x-menu a { + display: inline-block; + width: 100%; + color: inherit; + text-decoration: none; + font-size: 3em; + padding: .25em .5em; + } + + x-menu :is(a:hover, a.active) { + text-decoration: underline; + text-underline-offset: .25em; + } +`; + +export function Menu({$, title, links, onclose, openClose = ["☰","✕"]}) { + const list = links && Object.entries(links).map(([path, title]) => + html`${title}`); + const on = (_, state, onRender) => { + if ($.button.classList.toggle("open", state)) { + $.button.textContent = openClose[1]; + document.body.style.position = "fixed"; + } else { + $.button.textContent = openClose[0]; + document.body.style.position = ""; + if (onclose && !onRender) onclose(); + } + }; + + useEffect(() => on(null, false, true), new Date()); + return html` + + +
+ ${title && html`

${title}

`} +
${list}
+ ${$.self.children} +
+
`; +} diff --git a/docs/examples/frlkino/show.mjs b/docs/examples/frlkino/show.mjs new file mode 100644 index 0000000..0a7a32b --- /dev/null +++ b/docs/examples/frlkino/show.mjs @@ -0,0 +1,229 @@ +import {render, html, css, db} from "../../src/jsxy.mjs"; +css` + x-show { + display: block; + padding: 1em; + } + + x-show a { + color: inherit; + text-decoration: none; + } + + x-show .main-title { + display: block; + margin-top: 2em; + margin-bottom: 1rem; + font-size: 1.75em; + line-height: 1; + } + + x-show .meta { + display: flex; + margin-bottom: 3em; + } + + x-show .meta > * { + flex: 1; + text-align: center; + padding: .75em; + border: 1px solid var(--fg); + } + + x-show .meta > * + * { + border-left: none; + } + + x-show .meta .active { + background: var(--fg); + color: var(--bg); + } + + x-show .alternatives { + background: var(--ll); + padding: 1em; + margin: 3em 0; + } + + x-show table { + position: relative; + border-collapse: collapse; + width: 100%; + } + + x-show img { + height: 50vh; + object-fit: cover; + } + + x-show table caption { + text-align: left; + font-size: 1.5em; + margin-bottom: .5em; + line-height: 1; + } + + x-show tr { + border-top: 1px solid var(--fg); + border-bottom: 1px solid var(--fg); + } + + x-show td { + padding: .5em; + } + + x-show .date { + font-weight: bold; + padding: 0 1em; + } + + x-show .title { + display: block; + } + + x-show :is(.day, .cinema) { + font-weight: lighter; + } + + x-show .disabled { + color: var(--mg); + text-decoration: line-through; + pointer-events: none; + border-color: var(--mg); + } + + x-show .ticket { + display: inline-block; + position: relative; + font-size: .5em; + background: var(--lg); + margin-right: 2em; + } + + x-show .ticket::before { + content: "full"; + color: var(--bg); + display: inline-grid; + place-content: center; + background: inherit; + height: 4ch; + width: 7ch; + } + + x-show .ticket::after { + content: ""; + display: block; + background: inherit; + position: absolute; + top: 0; + left: 7ch; + height: 4ch; + width: 3ch; + border-left: 1px dashed var(--bg); + } + + x-show .ticket.bookable { + background: var(--fg); + } + + x-show .ticket.bookable::before { + content: "buy"; + } + + x-show .tags { + display: flex; + gap: .5em; + line-height: 1; + } + + x-show .tags span { + padding: 0 .1em; + border: 1px solid var(--lg); + color: var(--lg); + } + + x-show .tags span:first-of-type { + margin-left: auto; + } +`; + +export function Show({$, id, shows}) { + const show = shows[id]; + const alternatives = Object.values(shows).filter((other) => { + return show.normalizedTitle === other.normalizedTitle && + other.timestamp >= Date.now(); + }); + const normalizedTitle = getValue(alternatives, "normalizedTitle"); + const description = getValue(alternatives, "description", true); + const imgURL = getValue(alternatives, "img", true); + const trailerURL = getValue(alternatives, "trailer", true); + const isFav = db.favs?.[normalizedTitle]; + + const trs = alternatives + .sort((a, b) => a.timestamp - b.timestamp).map((show) => { + let [day, date] = show.date.split(","); + return html` + + + + ${show.title} +
${show.cinemaShortName}
+
+
+ ${show.version.original && html`ov`} + ${show.version.english && html`en`} +
+ +
+ + ${date} + ${day} + ${show.time} + `; + }); + + function onFav() { + const v = db.favs?.[normalizedTitle] ? undefined : show.id; + db.favs = {...db.favs, [normalizedTitle]: v}; + render($); + } + + return html` +
${normalizedTitle}
+ +
+ + + trailer + + +
+
+ + + + ${trs} + +
Events
+
+

+ ${description} +

+
`; +} + +function onShare(_, title) { + navigator.share({title, url: location.href}); +} + +function getValue(alternatives, key, longToShort) { + const sorted = alternatives.sort((a, b) => a[key]?.length || 0 - b[key]?.length || 0); + if (longToShort) sorted.reverse(); + return sorted[0][key]; +} diff --git a/docs/examples/hackernews/hackernews.css b/docs/examples/hackernews/hackernews.css new file mode 100644 index 0000000..35e9663 --- /dev/null +++ b/docs/examples/hackernews/hackernews.css @@ -0,0 +1,151 @@ +/* header */ + +nav { + top: 0; + position: sticky; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 1em; + margin-bottom: 1em; + border-bottom: 1px solid #aaa; + background-color: white; + word-break: initial; +} + +#logo { + font-weight: bold; + text-decoration: none; + color: inherit; + font-family: var(--font-mono); +} + +#settings { + display: flex; + gap: 0.25em; +} + +#top-value, #top-unit, #top-order { + border: none; + border-radius: 0; + border-bottom: 1px solid black; + width: 4ch; + background-color: white; + appearance: none; +} + +#top-order { + width: auto; +} + +/* stories */ + +#stories { + border-spacing: 0; + width: 100%; +} + +#stories thead { + font-family: var(--font-mono); + font-size: 0.75em; + color: #aaa; +} + +#stories a { + text-decoration: none; + color: inherit; +} + +#stories a:visited, #stories .old { + color: #888; +} + +#stories td { + padding: 0.75em 0.5em; +} + +#stories tr:hover { + background-color: #eee; +} + +#stories tbody .points, #stories tbody .comments { + text-align: right; +} + +#stories .title small, #stories .author { + font-size: 0.75em; + color: #888; + font-family: var(--font-mono); +} + +#stories .title small { + word-break: break-all; +} + +#stories .author { + margin-top: 0.75em; +} + +#stories .points, #stories .comments { + --alpha: 0%; + background-color: hsla(30, 100%, 80%, var(--alpha)); + word-break: keep-all; +} + +/* story */ + +#story { + margin: 1em; +} +#story #title { + font-size: 2em; +} + +#story #title small { + font-size: 0.75em; + color: #888; + font-family: var(--font-mono); +} + +#story #title a, #story #meta a, #story .header a { + text-decoration: none; + color: inherit; +} + +#story #meta { + color: #888; + margin-bottom: 2em; + border-bottom: 1px dashed #888; +} + +#story .comment { + margin: 1.25em 0; +} + +#story .comment .header { + margin-left: -0.5em; + padding: 0.5em; + color: #888; + --alpha: 0%; + background-color: hsla(30, 100%, 80%, var(--alpha)); +} + +#story .comment p { + margin: 0.5em 0 0.5em 0; +} + +#story .comment .count { + font-size: 0.75em; + color: #888 +} + +#story .comment > .comments { + margin-left: 1em; +} + +#story pre { + background-color: #eee; + max-width: 100%; + overflow-x: scroll; + +} diff --git a/docs/examples/hackernews/index.html b/docs/examples/hackernews/index.html new file mode 100644 index 0000000..c972563 --- /dev/null +++ b/docs/examples/hackernews/index.html @@ -0,0 +1,24 @@ + + + + hackernews + + + + + + + + + + + + diff --git a/docs/examples/hackernews/manifest.json b/docs/examples/hackernews/manifest.json new file mode 100644 index 0000000..31687aa --- /dev/null +++ b/docs/examples/hackernews/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "hackernews", + "short_name": "hn", + "display": "browser", +} diff --git a/docs/examples/hackernews/stories.html b/docs/examples/hackernews/stories.html new file mode 100644 index 0000000..226c77d --- /dev/null +++ b/docs/examples/hackernews/stories.html @@ -0,0 +1,109 @@ + + + diff --git a/docs/examples/hackernews/story.html b/docs/examples/hackernews/story.html new file mode 100644 index 0000000..d437c72 --- /dev/null +++ b/docs/examples/hackernews/story.html @@ -0,0 +1,100 @@ + + + + + diff --git a/docs/examples/style/index.html b/docs/examples/style/index.html new file mode 100644 index 0000000..4f962c6 --- /dev/null +++ b/docs/examples/style/index.html @@ -0,0 +1,97 @@ + + + + style + + + + +
+
+

Typography

+
+
+

Heading 1

+

Heading 2

+

Heading 3

+

A very long heading that breaks into multiple lines

+

Heading 4

+
+
+

A paragraph with + a link +

+

A paragraph with + bold, + marked, + underlined, + emphasised and + small text. Also inline code +

+
+
+
+ +
+

TODO

+
+
+ buttons + + +
+
+ inputs + + + + + + + +
+ +
+

Simple table with header

+ + + + + + + + + + + + + +
First nameLast name
JohnDoe
JaneDoe
+
    +
  • forms
  • + +
  • generic layouts (e.g. grid all rows same size, gaps, ...)
  • +
  • dark mode + +
      +
    • forms
    • + +
    • generic layouts (e.g. grid all rows same size, gaps, ...)
    • +
    • dark mode
    • +
    +
  • + +
+
+
col a
+
col b
+
col c
+
+
+ + + +
+ + diff --git a/docs/examples/style/style.css b/docs/examples/style/style.css new file mode 100644 index 0000000..82542b4 --- /dev/null +++ b/docs/examples/style/style.css @@ -0,0 +1,251 @@ +:root { + --bg: #fff; + --mg: #444; + --fg: #000; + --hl: hsla(95, 50%, 70%, 50%); + --ll: #ddd; + + --hf: sans-serif; + --bf: system-ui; + + --bw: .1rem; +} + +*, ::after, ::before { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + font: normal 1.2em/1.6 var(--bf); + background: var(--bg); + color: var(--fg); + min-height: 100%; + margin: 0; +} + +section { + display: flex; + flex-direction: column; + gap: 1em; +} + +h1 { font: bold 3em var(--hf); } +h2 { font: bold 1.75em var(--hf); } +h3 { font: bold 1.25em var(--hf); } +h4 { font: bold 0.75em var(--hf); } + +h1, h2, h3 { + color: var(--mg); + display: inline; + background: linear-gradient(180deg, var(--bg) 60%, var(--hl) 60%) no-repeat; + background-position: .25em; + line-height: 1.2; + width: max-content; +} + +:is(h1, h2, h3)::before, :is(h1, h2, h3)::after { + content: ""; + display: block; +} + +p { + margin: 1em 0; + line-height: 1.6; +} + +img { + display: block; + max-width: 100%; +} + +button { + display: inline-block; + font: inherit; + padding: .25em .5em; + margin: .5em 0; + background: var(--bg); + color: var(--fg); + border: var(--bw) solid var(--fg); + box-shadow: var(--bw) var(--bw) 0 var(--fg); + border-radius: 0; + cursor: pointer; +} + +button:is(:active, :focus) { + filter: brightness(90%); +} + +button:is(:active, :focus) { + outline: .25rem solid var(--hl); + box-shadow: none; + transform: translate(var(--bw), 0); +} + +:is(button,input):disabled:is(:disabled, :hover, :focus) { + color: var(--mg); + border-color: var(--mg); + outline: none; + box-shadow: none; + transform: none; + filter: none; + cursor: not-allowed; +} + +input, textarea, select { + font: inherit; + box-shadow: none; + font-size: 1em; + color: var(--fg); + background-color: var(--bg); + border: var(--bw) solid var(--fg); + border-radius: 0; + padding: .25em .5em; + margin: .5em 0; +} + +input:not([type=checkbox], [type=radio]) { + width: 100%; + appearance: none; +} + +input:is([type=checkbox], [type=radio]) { + transform: scale(1.5); + margin: 1em; +} + +input:not(:placeholder-shown):invalid { + border-color: red; +} + +:is(input, textarea, select):is(:active, :focus) { + outline: .25rem solid var(--hl); +} + +a { + color: var(--fg); + background: var(--hl); + padding: 0 .25em; + text-decoration: none; + box-shadow: 0 var(--bw) 0 var(--fg); +} + +a:visited { + background: var(--ll); +} + +table { + text-align: left; + font-size: 1rem; + border-collapse: collapse; + width: 100%; + border-spacing: 0; + margin: 2em 0; +} + +table td, th { + padding: .5em; + border: var(--bw) solid var(--fg); +} + +code { + border: var(--bw) solid var(--fg); + padding: .1em .2em; +} + +mark { + padding: .1em .2em; +} + +pre { + white-space: pre-wrap; + margin: 2em 0 2em 3em; +} + +blockquote { + font-style: italic; + margin: 2em 0 2em 2em; + color: var(--mg); +} + +.row { + display: flex; +} + +.space { + gap: .5em; + margin: 1em; +} + +.bg-hl { + background: var(--hl); +} + +.fg-hl { + color: var(--hl); +} + +form { + width: 100%; +} + +fieldset { + border: var(--bw) solid var(--fg); + padding: 1em; +} + +label { + font-weight: bold; + padding: 3em; +} + +ul { + padding: 0 2ch; +} + +li { + margin: 0.5em 0; +} + +.debug * { + outline: 1px solid #f00 !important; + opacity: 1 !important; + visibility: visible !important; +} + +.logo { + display: inline-block; + white-space: pre-line; + border: .2em solid black; + font: bold 1em monospace; + padding: .125em .25em; + margin: .5em; + pointer-events: visible; +} + +.space > * + * { + margin-top: var(--space, 1em); +} + +@media (pointer: fine) { + a:hover, button:hover { + filter: brightness(90%); + } +} + +@media (min-width: 70ch) { + main { + width: 70ch; + margin: 0 auto; + } + + .col { flex: 1; } + .col2 { flex: 0 0 calc(100% / 2); } + .col3 { flex: 0 0 calc(100% / 3); } + .col4 { flex: 0 0 calc(100% / 4); } + .col5 { flex: 0 0 calc(100% / 5); } + .col10 { flex: 0 0 calc(100% / 10); } +} diff --git a/docs/examples/todomvc/index.html b/docs/examples/todomvc/index.html new file mode 100644 index 0000000..433d23d --- /dev/null +++ b/docs/examples/todomvc/index.html @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + diff --git a/docs/examples/todomvc/todomvc.css b/docs/examples/todomvc/todomvc.css new file mode 100644 index 0000000..92047c9 --- /dev/null +++ b/docs/examples/todomvc/todomvc.css @@ -0,0 +1,185 @@ +html { + background-color: #f6f6f6; + font-family: var(--font-sans); +} + +.todoapp { + margin: 0 auto; + width: 40ch; + font-size: 1.5em; +} + +.todoapp h1 { + text-align: center; + font-size: 3em; + margin: 0.5em 0; + color: #bbb; +} + +.new-todo { + background-color: #fff; + border: none; + padding: 1em 2.5em; + width: 100%; + box-shadow: 0 0.5em 1em #ddd; +} + +.new-todo::placeholder { + color: #ddd; + font-style: italic; +} + +.main { + position: relative; + border-top: 1px solid #ddd; + box-shadow: 0 0.5em 1em #ddd; +} + +.toggle-all { + display: none; +} + +label[for=toggle-all]::before { + position: absolute; + top: -2em; + left: 0.5em; + content: "❯"; + display: block; + transform: rotate(90deg); + width: 1em; + color: #bbb; +} + +.todo-list { + background-color: #fff; +} + +.todo-item { + position: relative; + padding: 0.5em 0; + border-bottom: 1px solid #ddd; + display: flex; + align-items: center; +} + +.todo-item .toggle { + height: 1.5em; + width: 1.5em; + opacity: 0; +} + +.todo-item .toggle + label::before { + content: ""; + position: absolute; + pointer-events: none; + left: 0.25em; + text-align: center; + border-radius:50%; + width: 1.5em; + height: 1.5em; + border: 1px solid #ddd; +} + +.todo-item .toggle:checked + label::before { + content: "✓"; + color: var(--green); +} + +.todo-item label { + margin-left: 1em; + width: 100%; +} + +.todo-item.editing { + border: 1px solid grey; + margin-left: 2em; +} +.todo-item .edit { + width: 100%; + padding-left: 0.5em; + border: none; +} + +.todo-item.editing .toggle { + display: none; +} + + +.todo-item .toggle:checked + label { + text-decoration: line-through; + color: #ddd; +} + +.todo-item .destroy { + position: absolute; + right: 0.25em; + background: none; + border: none; +} + +.todo-item:hover .destroy::before { + content: "×"; + color: var(--red); +} + +.footer { + display: grid; + color: #999; + grid-template-columns: 1fr 1fr 1fr; + font-size: 1rem; + padding: 0.5em 1em; + background: #fff; + box-shadow: 0 0.1em 0 #ddd, + 0 0.6em 0em -0.2em #fff, + 0 0.7em 0 -0.2em #ddd, + 0 1.2em 0 -0.4em #fff, + 0 1.3em 0 -0.4em #ddd, + 0 1.3em 1em -0.4em #ddd; +} + +.filters { + display: flex; + justify-content: space-between; + width: 100%; +} + +.filters a { + text-decoration: none; + color: inherit; +} + +.filters a { + border: 1px solid transparent; + border-radius: 0.3em; + padding: 0.15em; +} + +.filters a.selected { + border: 1px solid #bbb; +} + +.filters a:hover { + border: 1px solid #ddd; +} + +.clear-completed { + background: none; + border: none; + color: inherit; + text-align: right; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin-top: 5em; + text-align: center; + color: #bbb; + font-size: 0.8em; +} + +:focus { + outline: none; +} diff --git a/docs/freiluftkino/actions.html b/docs/freiluftkino/actions.html new file mode 100644 index 0000000..3c01aa0 --- /dev/null +++ b/docs/freiluftkino/actions.html @@ -0,0 +1,53 @@ + + + + actions + + + + + + + diff --git a/docs/freiluftkino/details.html b/docs/freiluftkino/details.html new file mode 100644 index 0000000..d04ec23 --- /dev/null +++ b/docs/freiluftkino/details.html @@ -0,0 +1,156 @@ + + + + details + + + + + + + + + diff --git a/docs/freiluftkino/icon.png b/docs/freiluftkino/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..c605b0a7b126c7d2451ad08e8b862b72ea36b49e GIT binary patch literal 2754 zcmeAS@N?(olHy`uVBq!ia0y~yU{+vYV2a>i1B%QlYbpRzEX7WqAsj$Z!;#X#z`#}M z>EaktG3V{ggMrL}Jgko0xBdi+aTXkGeSX`!oaOxrO4;1FP7P*5H$fZC!Hg@#IWfU-J6g}t%ya&w07PX-{XlUUq=-TUL_nKQt$ss1ec z;49$4bxzE3c7_AXXV0|U%lII80Lm|5jWP-bFa!>~t!0+^DzxfV-Qy!5XL!2$xvX + + + + + + + + + + + + + + + + + + + + + freiluftkino + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/freiluftkino/list.html b/docs/freiluftkino/list.html new file mode 100644 index 0000000..ea2277c --- /dev/null +++ b/docs/freiluftkino/list.html @@ -0,0 +1,152 @@ + + + + list + + + + + + + + + + diff --git a/docs/freiluftkino/manifest.webmanifest b/docs/freiluftkino/manifest.webmanifest new file mode 100644 index 0000000..e1b42ca --- /dev/null +++ b/docs/freiluftkino/manifest.webmanifest @@ -0,0 +1,14 @@ +{ + "version": "0.0.2", + "display": "standalone", + "name": "freiluftkino", + "short_name": "flk", + "background_color": "#000", + "theme_color": "#000", + "icons": [ + { + "src": "icon.png", + "sizes": "any" + } + ] +} diff --git a/docs/freiluftkino/module.css b/docs/freiluftkino/module.css new file mode 100644 index 0000000..ecfb846 --- /dev/null +++ b/docs/freiluftkino/module.css @@ -0,0 +1,27 @@ +html { + color: #eee; + background-color: #000; +} + +img { + height: 100%; + object-fit: cover; +} + +a { + color: inherit; + text-decoration: none; +} + +.button { + border: 0.1em solid #fff; + display: inline-block; + line-height: 2em; + font-weight: bold; + padding: 0.25em 0.5em; + border-radius: 0.25em; +} + +x-icon-heart.favorited svg{ + fill: red; +} diff --git a/docs/freiluftkino/nav.html b/docs/freiluftkino/nav.html new file mode 100644 index 0000000..39a73b6 --- /dev/null +++ b/docs/freiluftkino/nav.html @@ -0,0 +1,91 @@ + + + + nav + + + + + + + diff --git a/docs/frlkino/assets/logo.svg b/docs/frlkino/assets/logo.svg new file mode 100644 index 0000000..01c5e50 --- /dev/null +++ b/docs/frlkino/assets/logo.svg @@ -0,0 +1,8 @@ + + + + FRL + KNO + diff --git a/docs/frlkino/assets/manifest.json b/docs/frlkino/assets/manifest.json new file mode 100644 index 0000000..6206b97 --- /dev/null +++ b/docs/frlkino/assets/manifest.json @@ -0,0 +1,15 @@ +{ + "version": "0.0.1", + "display": "standalone", + "name": "freiluftkino", + "short_name": "frlkino", + "background_color": "white", + "theme_color": "white", + "start_url": "../", + "icons": [ + { + "src": "logo.svg", + "sizes": "any" + } + ] +} diff --git a/docs/frlkino/assets/style.css b/docs/frlkino/assets/style.css new file mode 100644 index 0000000..2c49a25 --- /dev/null +++ b/docs/frlkino/assets/style.css @@ -0,0 +1,151 @@ +:root { + --bg: #fff; + --mg: #666; + --lg: #bbb; + --fg: #000; + --hl: hsla(95, 50%, 70%, 50%); + --ll: #eee; + + --hf: sans-serif; + --bf: system-ui; + + --bw: .1rem; +} + +*, ::after, ::before { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + font: normal 1rem/1.6 var(--bf); + background: var(--bg); + color: var(--fg); + min-height: 100%; +} + +img { + display: block; + max-width: 100%; + height: auto; +} + +.full-width { + width: 100vw !important; + max-width: 100vw !important; + margin-left: calc(50% - 50vw) !important; + margin-right: 0 !important; +} + + +body, h1, h2, h3 { + margin: 0; +} + +button, input, textarea, select { + font: inherit; + color: inherit; + background: inherit; +} + +x-app nav { + display: flex; + justify-content: space-between; + align-items: center; + position: sticky; + top: 0; + background: white; + font-size: 2em; + z-index: 1; +} + +x-app nav .logo { + width: 1em; + height: 1em; + margin: .25em; +} + +x-shows .shows, .favs { + display: grid; + grid-auto-flow: column; + grid-auto-columns: 33%; + gap: 1em; + overflow-x: auto; +} + +.favs-wrapper { + margin: 1em; + position: absolute; + bottom: 0; +} + +@media (min-width: 70ch) { + main { + max-width: 70ch; + margin: 0 auto; + } + x-shows .shows { + grid-auto-columns: 12.5%; + } +} + +x-shows .day { + margin-top: 1em; + margin-left: 1em; +} + +.show { + display: grid; + grid-template-rows: 1fr auto auto; + font-size: 1rem; + gap: .5em; + color: inherit; + text-decoration: none; +} + +.show img { + object-fit: cover; + height: 100%; + width: 100%; + aspect-ratio: 2/3; + border: none; +} + +img[src=""] { + background: black; +} + +.show .title { + line-height: 1; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +.show .cinema { + line-height: 1; + color: grey; + font-size: 0.75em; + margin-top: -0.5em; + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; +} + +x-movies { + display: grid; + gap: 1em; + grid-template-columns: repeat(auto-fill, minmax(25%, 1fr)); + padding: 1em; +} + +.bookable { + position: relative; +} + +.hidden { + display: none; +} diff --git a/docs/frlkino/filters.mjs b/docs/frlkino/filters.mjs new file mode 100644 index 0000000..57f85bf --- /dev/null +++ b/docs/frlkino/filters.mjs @@ -0,0 +1,188 @@ +import {html, css, render, query, useEffect, sub} from "../../src/jsxy.mjs"; + +css` + x-filters form { + display: flex; + position: relative; + background: var(--ll); + border-top: 1px solid var(--mg); + border-bottom: 1px solid var(--mg); + } + + x-filters .item > button { + position: relative; + padding: .5em 1em; + border: none; + border-right: 1px solid var(--mg); + background: var(--ll); + } + + x-filters .item > button.enabled::after { + content: ""; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: .125em; + background: var(--fg); + } + + x-filters .item.active > button { + background: var(--bg); + border-bottom: 1px solid var(--bg); + margin-bottom: -1px; + } + + x-filters .item:last-of-type.active > button { + border-right: 1px solid var(--mg); + } + + x-filters .dropdown { + display: none; + } + + x-filters .active .dropdown { + position: absolute; + display: block; + background: var(--bg); + top: calc(100% + 1px); + left: 0; + width: 100%; + border-bottom: 1px solid var(--fg); + } + + x-filters .active .cinemas { + display: flex; + gap: 1em; + flex-wrap: wrap; + padding: 4em 1em 1em 1em; + } + + x-filters .cinema input { + display: none; + } + + x-filters .cinemas .clear { + position: absolute; + top: 0; + right: 0; + margin: 1em; + border: none; + background: var(--fg); + color: var(--bg); + } + + x-filters .cinema label { + border: 1px solid var(--fg); + padding: .25em; + } + + x-filters .cinema input:checked + label { + color: var(--bg); + background: var(--fg); + } + + x-filters .active .search { + padding: 1em; + display: flex; + justify-content: stretch; + } + + x-filters .search input { + width: 100%; + border: 1px solid var(--fg); + padding: .25em; + } + + x-filters .search .clear { + border: 1px solid var(--fg); + border-left: none; + padding: 0 1em; + } + + x-filters .lang input { + display: none; + } + + x-filters .lang label { + display: block; + padding: .5em 1em; + border-right: 1px solid var(--mg); + } + + x-filters .lang input:checked + label { + background: var(--fg); + color: var(--bg); + } +`; + +export function Filters({$, cinemas}) { + useEffect(() => { + function updateButtons() { + const config = query.config || {}; + $.cinemasButton.classList.toggle("enabled", Object.keys(config.cinemas || {}).length); + $.searchButton.classList.toggle("enabled", !!config.searchTitle); + } + updateButtons(); + return sub(query, "config", updateButtons); + }); + + function toggle(e) { + const node = e.target.parentNode; + for (let el of $.filters.querySelectorAll(".active")) { + if (el !== node) el.classList.toggle("active", 0); + } + node.classList.toggle("active"); + } + + function onCinemaClear() { + query.config = {...query.config, cinemas: {}}; + render($); + } + + function onSearchClear() { + query.config = {...query.config, searchTitle: null}; + render($); + } + + const tags = cinemas.map(x => { + return html` +
+ + +
` + }); + + return html` + +
+
+ + +
+
+ + +
+
+ +
+ + ${tags} +
+
+
+ +
+ + +
+
+
+
+ `; +} diff --git a/docs/frlkino/index.html b/docs/frlkino/index.html new file mode 100644 index 0000000..bb2c4ff --- /dev/null +++ b/docs/frlkino/index.html @@ -0,0 +1,16 @@ + + + + + + + + freiluftkino + + + + + + + \ No newline at end of file diff --git a/docs/frlkino/index.mjs b/docs/frlkino/index.mjs new file mode 100644 index 0000000..34104b0 --- /dev/null +++ b/docs/frlkino/index.mjs @@ -0,0 +1,127 @@ +import {html, css, route, render, query, db, useEffect, sub} from "../../src/jsxy.mjs"; +import {Menu} from "./menu.mjs"; +import {Show} from "./show.mjs"; +import {Filters} from "./filters.mjs"; + +const history = await fetch("https://niklasfasching.github.io/freiluftkino/history.json").then(r => r.json()); +const shows = await fetch("https://niklasfasching.github.io/freiluftkino/shows.json").then(r => r.json()); +const showsByDate = groupShowsBy(shows, "date"); +const showsByMovie = groupShowsBy(shows, "normalizedTitle"); +const cinemas = [...new Set(Object.values(shows).map(x => x.cinemaShortName))].sort(); + +route({ + "/": wrap(ByDate, true), + "/movies": wrap(ByMovie, true), + "/show/{id}": wrap(Show), +}, document.body); + +function wrap(tag, showFilters) { + return function(props) { + function onClose() { + if (route.path === "/") render(props.$.main) + } + return html` + <${Nav} key=nav ...=${{shows, showsByMovie, onClose}}/> + ${showFilters && html`<${Filters} key=filters cinemas=${cinemas} refs=${props.$}/>`} +
+ <${tag} key=${tag.name} ...=${{history, shows, cinemas, showsByDate, showsByMovie}} ...=${props} $main/> +
+
` + } +} + +function Nav({$, onClose, shows, showsByMovie}) { + const links = { + "/": "By Date", + "/movies/": "By Movie", + }; + + useEffect(() => sub(db, "favs", () => render($))); + + const favs = Object.keys(db.favs || {}).map(normalizedTitle => { + const show = showsByMovie[normalizedTitle]?.filter(x => x.timestamp > Date.now())[0]; + if (!show) return; + return html` +
+ +
${show.title}
+
${show.cinemaShortName}
+
`; + }); + + return html` + `; +} + +function ByDate({$, showsByDate}) { + useEffect(() => sub(query, "config", () => render($))); + const days = Object.entries(showsByDate).map(([date, shows]) => { + shows = filterByConfig(shows) + if (!shows.length) return; + shows = shows.map(x => html` + + +
${x.title}
+
${x.time} | ${x.cinemaShortName}
+
+ `); + return html`
+

${date.split(",")[1]}

+
${shows}
+
` + }); + return html`${days.filter(Boolean)}`; +} + +function ByMovie({$, showsByMovie}) { + useEffect(() => sub(query, "config", () => render($))); + const movies = Object.entries(showsByMovie).map(([normalizedTitle, shows]) => { + shows = filterByConfig(shows) + if (!shows.length) return; + const show = shows[0]; + return html` + + +
${show.title}
+
${show.cinemaShortName}
+
+ `; + }); + return html`${movies}`; +} + +function filterByConfig(shows) { + const activeCinemas = query.config?.cinemas || {}; + if (Object.keys(activeCinemas).length) { + shows = shows.filter(x => activeCinemas[x.cinemaShortName]); + } + if (query.config?.searchTitle) { + shows = shows.filter(x => x.normalizedTitle.includes(query.config?.searchTitle.toUpperCase())) + } + if (query.config?.ov || query.config?.en) { + shows = shows.filter(x => { + return (query.config.ov && x.version.original) || (query.config.en && x.version.english); + }) + } + return shows +} + +function groupShowsBy(shows, key) { + return Object.values(shows) + .filter(x => x.timestamp >= Date.now()) + .sort((a, b) => a.timestamp - b.timestamp) + .reduce((m, x) => { + m[x[key]] = (m[x[key]] || []).concat(x).sort((x, y) => x.time > y.time); + return m; + }, {}); +} diff --git a/docs/frlkino/menu.mjs b/docs/frlkino/menu.mjs new file mode 100644 index 0000000..eda1338 --- /dev/null +++ b/docs/frlkino/menu.mjs @@ -0,0 +1,69 @@ +import {html, css, route, useEffect} from "../../src/jsxy.mjs"; + +css` + x-menu button { + border: none; + padding: 0 .25em; + cursor: pointer; + } + + x-menu .overlay { + position: absolute; + top: 100%; + left: 0; + height: calc(100vh - 100%); + width: 0; + background: var(--bg); + padding-top: 2em; + transform: translateX(100vw); + transform-origin: top right; + overflow: hidden; + transition: transform .1s ease-in-out, transform .1s ease-in-out; + font-size: 1rem; + } + + x-menu .open + .overlay { + transform: translateX(0); + width: 100vw; + } + + x-menu a { + display: inline-block; + width: 100%; + color: inherit; + text-decoration: none; + font-size: 3em; + padding: .25em .5em; + } + + x-menu :is(a:hover, a.active) { + text-decoration: underline; + text-underline-offset: .25em; + } +`; + +export function Menu({$, title, links, onclose, openClose = ["☰","✕"]}) { + const list = links && Object.entries(links).map(([path, title]) => + html`${title}`); + const on = (_, state, onRender) => { + if ($.button.classList.toggle("open", state)) { + $.button.textContent = openClose[1]; + document.body.style.position = "fixed"; + } else { + $.button.textContent = openClose[0]; + document.body.style.position = ""; + if (onclose && !onRender) onclose(); + } + }; + + useEffect(() => on(null, false, true), new Date()); + return html` + + +
+ ${title && html`

${title}

`} +
${list}
+ ${$.self.children} +
+
`; +} diff --git a/docs/frlkino/show.mjs b/docs/frlkino/show.mjs new file mode 100644 index 0000000..0a7a32b --- /dev/null +++ b/docs/frlkino/show.mjs @@ -0,0 +1,229 @@ +import {render, html, css, db} from "../../src/jsxy.mjs"; +css` + x-show { + display: block; + padding: 1em; + } + + x-show a { + color: inherit; + text-decoration: none; + } + + x-show .main-title { + display: block; + margin-top: 2em; + margin-bottom: 1rem; + font-size: 1.75em; + line-height: 1; + } + + x-show .meta { + display: flex; + margin-bottom: 3em; + } + + x-show .meta > * { + flex: 1; + text-align: center; + padding: .75em; + border: 1px solid var(--fg); + } + + x-show .meta > * + * { + border-left: none; + } + + x-show .meta .active { + background: var(--fg); + color: var(--bg); + } + + x-show .alternatives { + background: var(--ll); + padding: 1em; + margin: 3em 0; + } + + x-show table { + position: relative; + border-collapse: collapse; + width: 100%; + } + + x-show img { + height: 50vh; + object-fit: cover; + } + + x-show table caption { + text-align: left; + font-size: 1.5em; + margin-bottom: .5em; + line-height: 1; + } + + x-show tr { + border-top: 1px solid var(--fg); + border-bottom: 1px solid var(--fg); + } + + x-show td { + padding: .5em; + } + + x-show .date { + font-weight: bold; + padding: 0 1em; + } + + x-show .title { + display: block; + } + + x-show :is(.day, .cinema) { + font-weight: lighter; + } + + x-show .disabled { + color: var(--mg); + text-decoration: line-through; + pointer-events: none; + border-color: var(--mg); + } + + x-show .ticket { + display: inline-block; + position: relative; + font-size: .5em; + background: var(--lg); + margin-right: 2em; + } + + x-show .ticket::before { + content: "full"; + color: var(--bg); + display: inline-grid; + place-content: center; + background: inherit; + height: 4ch; + width: 7ch; + } + + x-show .ticket::after { + content: ""; + display: block; + background: inherit; + position: absolute; + top: 0; + left: 7ch; + height: 4ch; + width: 3ch; + border-left: 1px dashed var(--bg); + } + + x-show .ticket.bookable { + background: var(--fg); + } + + x-show .ticket.bookable::before { + content: "buy"; + } + + x-show .tags { + display: flex; + gap: .5em; + line-height: 1; + } + + x-show .tags span { + padding: 0 .1em; + border: 1px solid var(--lg); + color: var(--lg); + } + + x-show .tags span:first-of-type { + margin-left: auto; + } +`; + +export function Show({$, id, shows}) { + const show = shows[id]; + const alternatives = Object.values(shows).filter((other) => { + return show.normalizedTitle === other.normalizedTitle && + other.timestamp >= Date.now(); + }); + const normalizedTitle = getValue(alternatives, "normalizedTitle"); + const description = getValue(alternatives, "description", true); + const imgURL = getValue(alternatives, "img", true); + const trailerURL = getValue(alternatives, "trailer", true); + const isFav = db.favs?.[normalizedTitle]; + + const trs = alternatives + .sort((a, b) => a.timestamp - b.timestamp).map((show) => { + let [day, date] = show.date.split(","); + return html` + + + + ${show.title} +
${show.cinemaShortName}
+
+
+ ${show.version.original && html`ov`} + ${show.version.english && html`en`} +
+ +
+ + ${date} + ${day} + ${show.time} + `; + }); + + function onFav() { + const v = db.favs?.[normalizedTitle] ? undefined : show.id; + db.favs = {...db.favs, [normalizedTitle]: v}; + render($); + } + + return html` +
${normalizedTitle}
+ +
+ + + trailer + + +
+
+ + + + ${trs} + +
Events
+
+

+ ${description} +

+
`; +} + +function onShare(_, title) { + navigator.share({title, url: location.href}); +} + +function getValue(alternatives, key, longToShort) { + const sorted = alternatives.sort((a, b) => a[key]?.length || 0 - b[key]?.length || 0); + if (longToShort) sorted.reverse(); + return sorted[0][key]; +} diff --git a/docs/hackernews/hackernews.css b/docs/hackernews/hackernews.css new file mode 100644 index 0000000..35e9663 --- /dev/null +++ b/docs/hackernews/hackernews.css @@ -0,0 +1,151 @@ +/* header */ + +nav { + top: 0; + position: sticky; + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5em 1em; + margin-bottom: 1em; + border-bottom: 1px solid #aaa; + background-color: white; + word-break: initial; +} + +#logo { + font-weight: bold; + text-decoration: none; + color: inherit; + font-family: var(--font-mono); +} + +#settings { + display: flex; + gap: 0.25em; +} + +#top-value, #top-unit, #top-order { + border: none; + border-radius: 0; + border-bottom: 1px solid black; + width: 4ch; + background-color: white; + appearance: none; +} + +#top-order { + width: auto; +} + +/* stories */ + +#stories { + border-spacing: 0; + width: 100%; +} + +#stories thead { + font-family: var(--font-mono); + font-size: 0.75em; + color: #aaa; +} + +#stories a { + text-decoration: none; + color: inherit; +} + +#stories a:visited, #stories .old { + color: #888; +} + +#stories td { + padding: 0.75em 0.5em; +} + +#stories tr:hover { + background-color: #eee; +} + +#stories tbody .points, #stories tbody .comments { + text-align: right; +} + +#stories .title small, #stories .author { + font-size: 0.75em; + color: #888; + font-family: var(--font-mono); +} + +#stories .title small { + word-break: break-all; +} + +#stories .author { + margin-top: 0.75em; +} + +#stories .points, #stories .comments { + --alpha: 0%; + background-color: hsla(30, 100%, 80%, var(--alpha)); + word-break: keep-all; +} + +/* story */ + +#story { + margin: 1em; +} +#story #title { + font-size: 2em; +} + +#story #title small { + font-size: 0.75em; + color: #888; + font-family: var(--font-mono); +} + +#story #title a, #story #meta a, #story .header a { + text-decoration: none; + color: inherit; +} + +#story #meta { + color: #888; + margin-bottom: 2em; + border-bottom: 1px dashed #888; +} + +#story .comment { + margin: 1.25em 0; +} + +#story .comment .header { + margin-left: -0.5em; + padding: 0.5em; + color: #888; + --alpha: 0%; + background-color: hsla(30, 100%, 80%, var(--alpha)); +} + +#story .comment p { + margin: 0.5em 0 0.5em 0; +} + +#story .comment .count { + font-size: 0.75em; + color: #888 +} + +#story .comment > .comments { + margin-left: 1em; +} + +#story pre { + background-color: #eee; + max-width: 100%; + overflow-x: scroll; + +} diff --git a/docs/hackernews/index.html b/docs/hackernews/index.html new file mode 100644 index 0000000..25e8e36 --- /dev/null +++ b/docs/hackernews/index.html @@ -0,0 +1,738 @@ + + + + + + + + + + + + + hackernews + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/hackernews/manifest.json b/docs/hackernews/manifest.json new file mode 100644 index 0000000..31687aa --- /dev/null +++ b/docs/hackernews/manifest.json @@ -0,0 +1,5 @@ +{ + "name": "hackernews", + "short_name": "hn", + "display": "browser", +} diff --git a/docs/hackernews/stories.html b/docs/hackernews/stories.html new file mode 100644 index 0000000..226c77d --- /dev/null +++ b/docs/hackernews/stories.html @@ -0,0 +1,109 @@ + + + diff --git a/docs/hackernews/story.html b/docs/hackernews/story.html new file mode 100644 index 0000000..d437c72 --- /dev/null +++ b/docs/hackernews/story.html @@ -0,0 +1,100 @@ + + + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..21b3c67 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,36 @@ + + + + + + + + + + +

freiluftkino

+ +

frlkino

+ +

hackernews

+ +

style

+ +

todomvc

+ + + diff --git a/docs/modules/app/index.html b/docs/modules/app/index.html new file mode 100644 index 0000000..e9c8ecd --- /dev/null +++ b/docs/modules/app/index.html @@ -0,0 +1,195 @@ + + + app + + + + + + + diff --git a/docs/modules/card/card.css b/docs/modules/card/card.css new file mode 100644 index 0000000..719e838 --- /dev/null +++ b/docs/modules/card/card.css @@ -0,0 +1,53 @@ +x-card { + display: flex; + flex-direction: column; + overflow: hidden; + font-family: var(--font-sans); + border: 1px solid #bbb; + border-radius: 0.5em; + box-shadow: 0.25em 0.25em 2em 0.1em #bbb; + margin: 1em; +} + +x-card .cover { + height: 50%; + background-color: #666; + flex-shrink: 0; +} + +x-card .tags { + display: flex; + gap: 1em; +} + +x-card .tag { + border: 1px solid #bbb; + border-radius: 0.5em; + padding: 0.25em 0.5em; +} + +x-card section { + display: flex; + flex-direction: column; + overflow: hidden; + padding: 1em; + font-size: var(--font-size); +} + +x-card .title { + margin-top: 0.25em; + margin-bottom: 0.5em; + font-size: 2em; + font-weight: bold; +} + +x-card .subtitle { + margin-top: -0.25em; + margin-bottom: 1.5em; + font-weight: 500; + color: #666; +} + +x-card .content { + overflow: auto; +} diff --git a/docs/modules/card/index.html b/docs/modules/card/index.html new file mode 100644 index 0000000..8d74f36 --- /dev/null +++ b/docs/modules/card/index.html @@ -0,0 +1,39 @@ + + + hackernews + + + + + + + + + diff --git a/docs/modules/chart/index.html b/docs/modules/chart/index.html new file mode 100644 index 0000000..7d57671 --- /dev/null +++ b/docs/modules/chart/index.html @@ -0,0 +1,122 @@ + + + + chart + + + + + + + + + + + + + + diff --git a/docs/modules/icon/index.html b/docs/modules/icon/index.html new file mode 100644 index 0000000..5af6586 --- /dev/null +++ b/docs/modules/icon/index.html @@ -0,0 +1,152 @@ + + + + icon + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/src/base.css b/docs/src/base.css new file mode 100644 index 0000000..a90873b --- /dev/null +++ b/docs/src/base.css @@ -0,0 +1,58 @@ +html, body { + box-sizing: border-box; + word-break: break-word; + min-height: 100%; + padding: 0; + margin: 0; + font-family: var(--font-sans); +} + +*, *:before, *:after { + box-sizing: inherit; +} + +img { + display: block; + height: auto; + max-width: 100%; +} + +pre { + white-space: pre-wrap; +} + +ul[class], ol[class] { + list-style: none; + margin: 0; + padding: 0; +} + +:root { + --font-sans: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen-Sans, Ubuntu, Cantarell, "Helvetica Neue", sans-serif; + --font-serif: Constantia, "Lucida Bright", Lucidabright, "Lucida Serif", Lucida, "DejaVu Serif", "Bitstream Vera Serif", "Liberation Serif", Georgia, serif; + --font-mono: SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + + --green: hsl(95, 40%, 60%); + --yellow: hsl(45, 100%, 70%); + --red: hsl(350, 85%, 60%); +} + +textarea, select, input, button { + padding: 0.5em; + background-color: #fff; + border-radius: 0.25em; + font-size: 100%; +} + +select, textarea { + width: 100%; +} + +select option { + overflow: hidden; + text-overflow: ellipsis; +} + +h4 { + margin-bottom: 0.2em; +} diff --git a/docs/src/bundler.mjs b/docs/src/bundler.mjs new file mode 100644 index 0000000..a4c07a9 --- /dev/null +++ b/docs/src/bundler.mjs @@ -0,0 +1,71 @@ +import {compile} from "./compiler.mjs"; + +export async function bundle(url, basePath = "/") { + const asDataURL = basePath === null, + xModules = await loadXModules(absoluteURL(url, location), basePath); + const xComponents = xModules.map(x => x.xComponents).join("\n"); + const moduleCodeBlocks = xModules.flatMap(x => x.modules.map(m => [m.text, x.url])); + const styles = xModules.flatMap(x => x.styles); + if (asDataURL) { + for (const module of (xModules[0].xTemplates)) module.remove(); + const imports = moduleCodeBlocks.slice(xModules[0].modules.length) + .map(([code, path], i) => `//# sourceURL=${path}.${i}.js\n${code}`) + .concat(`//# sourceURL=xmComponents.js\n${xComponents}`) + .map(src => `import "${dataURL(src)}";`); + window.document.head.append(...styles); + return dataURL(imports.join("\n")); + } + const {document, xImports, xTemplates, modules} = xModules[0]; + for (let el of [...xImports, ...xTemplates, ...modules]) el.remove(); + document.querySelectorAll("[x-dev]").forEach(el => el.remove()); + const moduleHTML = moduleCodeBlocks.map(([code]) => code).concat(xComponents).map(code => { + return ``; + }).join("\n"); + const styleHTML = styles.map(s => s.outerHTML).join("\n"); + document.head.innerHTML = "\n" + moduleHTML + "\n" + styleHTML + "\n" + document.head.innerHTML; + return "\n" + document.documentElement.outerHTML; +} + +export async function loadXModules(url, basePath, loaded = {}) { + if (loaded[url]) return []; + else loaded[url] = true; + const document = url === location.href ? window.document : await loadDocument(url); + const styles = all(document, `style, link[rel=stylesheet]`), + modules = all(document, `[type*=module]:not([src])`), + xTemplates = all(document, `[type*=x-template][id]`), + xImports = all(document, `[type*=x-module][src]`); + const xComponents = xTemplates.map(({id, outerHTML}) => compile(id, outerHTML)).join("\n"), + xModules = await Promise.all(xImports.map(x => loadXModules(absoluteURL(x.getAttribute("src"), url), basePath, loaded))); + for (let s of styles) s.href = rebaseURL(s.getAttribute("href"), url, basePath); + for (let m of modules) m.text = rebaseModuleImports(m.text, url, basePath); + return [{url, document, modules, styles, xImports, xTemplates, xComponents}, ...xModules.flat()]; +} + +export function rebaseModuleImports(code, moduleURL, basePath) { + const f = (_ , from, __, url) => `${from}'${rebaseURL(url, moduleURL, basePath)}'`; + return code.replaceAll(/^\s*(import\s+(.*from\s+)?)["'](.+)["']/gm, f); +} + +export function dataURL(string) { + return `data:text/javascript,${encodeURIComponent(string)}`; +} + +export function rebaseURL(url, baseURL, basePath) { + if (basePath?.endsWith("/")) basePath = basePath.slice(0, -1); + url = absoluteURL(url, baseURL, typeof basePath === "string"); + return url.startsWith("/") && basePath ? basePath + url : url; +} + +function absoluteURL(url, baseURL, rootRelative) { + if (/^(https?|data):/.test(url)) return url.toString(); + const {href, pathname} = new URL(url, baseURL); + return rootRelative ? pathname : href; +} + +function all(document, selector) { + return [...document.querySelectorAll(selector)].filter(el => !el.closest("[x-dev]")); +} + +function loadDocument(url) { + return fetch(url).then(async (r) => new DOMParser().parseFromString(await r.text(), "text/html")); +} diff --git a/docs/src/compiler.mjs b/docs/src/compiler.mjs new file mode 100644 index 0000000..a32e57d --- /dev/null +++ b/docs/src/compiler.mjs @@ -0,0 +1,243 @@ +import {parse, parseValue, parseValueParts} from "./parser.mjs"; + +const macros = [ + [/^\.inject:/, injectMacro], + [/^\.if$/, ifMacro], + [/^\.for$/, forMacro], + [/^\.on:/, onMacro], + [/^\.bind:/, bindMacro], + [/^\.\./, classMacro], + [/^--/, cssVarMacro], + [/^#/, idMacro], + [/^\|.*\|$/, slotMacro] +]; + +const splatRegexp = /^\s*{\s*\.\.\.\s*(.+)\s*}\s*$/; + +function bindMacro(vnode, $, key, value) { + const [_, property] = key.split(":"); + generateVnode(vnode, $); + const event = ["input", "textarea"].includes(vnode.tag) && vnode.properties.type !== "checkbox" ? "keyup" : "change"; + const node = generateLocalNodeRef($, vnode, "bind"); + let getValue = `${node}["${property}"]`, setValue = `${node}["${property}"] = ${value}`; + if (property === "options") { + getValue = `Object.fromEntries([...${node}.selectedOptions].map(o => [o.value, true]))`; + setValue = `for (let o of ${node}.options) o.selected = (${value})?.[o.value] || false`; + } + $.create += `if (${value} !== undefined) ${setValue};\n + ${node}.addEventListener("${event}", () => ${value} = ${getValue});\n`; + $.update += `if (document.activeElement !== ${node} && ${value} !== undefined) ${setValue};\n`; +} + +function classMacro(vnode, $, key, value) { + if (value === "true" || value === "") vnode.properties.class = ((vnode.properties.class || "") + " " + key.slice(2)).trim(); + else { + $.create += `${vnode.ref}.classList.toggle(${parseValue(key.slice(2))[0]}, !!(${value}));\n`; + $.update += `${vnode.ref}.classList.toggle(${parseValue(key.slice(2))[0]}, !!(${value}));\n`; + } + generateVnode(vnode, $); +} + +function slotMacro(vnode, $, key, value) { + vnode.properties["x-slot"] = key.slice(1, -1); + generateVnode(vnode, $); +} + +function cssVarMacro(vnode, $, key, value) { + $.create += `${vnode.ref}.style.setProperty(${parseValue(key.slice(2))[0]}, "${value}");\n`; + $.update += `${vnode.ref}.style.setProperty(${parseValue(key.slice(2))[0]}, "${value}");\n`; + generateVnode(vnode, $); +} + +function idMacro(vnode, $, key, value) { + vnode.properties.id = key.slice(1).trim(); + generateVnode(vnode, $); +} + +function injectMacro(vnode, $, key, value) { + const [_, selector] = key.split(":"); + $.create += `$["${value}"] = xm.inject($, "${selector.toUpperCase()}");\n`; + generateVnode(vnode, $); +} + +function onMacro(vnode, $, key, value) { + generateVnode(vnode, $); + const [_on_, event, ...modifiers] = key.split(":"); + if (event === "update" || event === "create") { + const node = generateLocalNodeRef($, vnode, "on"); + $.create += `function ${node}_fn() { ${value} }\n`; + $[event] += `setTimeout(() => ${node}_fn.call(${node}));\n`; + } else { + let after = "$.app.update();"; + if (modifiers.includes("no")) after = ""; + $.create += `${vnode.ref}.addEventListener("${event}", function($event) { + ${value}; + ${after} + });\n`; + } +} + +function ifMacro(vnode, $, key, value) { + const _ = prefix("if"); + generateClosure(vnode, $, _, "xm.symbols.updateIfNode",); + $.create += `let ${_}connected = xm.nodeIf((${value}), ${_}anchor, ${_}anchor, $, ${_}create); + ${vnode.ref} = ${_}connected;\n`; + $.update += `${_}connected = xm.nodeIf((${value}), ${_}connected, ${_}anchor, $, ${_}create);\n`; +} + +function forMacro(vnode, $, key, value) { + let _ = prefix("for"), + [name, inOrOf, values] = value.split(/ (of|in) /); + if (inOrOf === "in") values = `Object.entries(${values} || {})`; + generateClosure(vnode, $, _, + "xm.symbols.updateChildNode", + `let ${name} = _args[0];`, + `${name} = _args[0];`); + $.create += `const ${_}values = [], ${_}nodes = []; + xm.updateChildNodes(${_}anchor.parentNode, ${_}anchor, ${_}nodes, ${_}values, ${values} || [], $, ${_}create);\n`; + $.update += `xm.updateChildNodes(${_}anchor.parentNode, ${_}anchor, ${_}nodes, ${_}values, ${values} || [], $, ${_}create);\n`; +} + +export function compile(name, template) { + const $ = {html: "", create: "", update: ""}, + vnode = Object.assign(parse(template)[0], {ref: "this"}); + const assignedProps = "[" + (vnode.properties["x-props"] || "").replaceAll(/(\w+)/g, `"$1",`) + "]"; + delete vnode.properties.id; + delete vnode.properties.type; + delete vnode.properties["x-props"]; + generateVnode(vnode, $); + return `xm.register("${name}", \`${$.html.replaceAll("`", "\\`")}\`, function() { + const $ = this; + ${$.create} + return () => { + ${$.update} + }; + }, ${assignedProps});\n`; +} + +function generateClosure(vnode, $, _, updateKey, beforeCreate = "", beforeUpdate = "") { + const $$ = {create: "", update: "", html: ""}, node = generateNodeRef($, vnode, "closure"); + generateVnode(vnode, $$); + let inSVG = false; + while (vnode = vnode.parent) inSVG |= vnode.tag === "svg"; + $.html += ""; + $.create += `let ${_}anchor = ${node}, ${_}node = xm.fragment(\`${$$.html.replaceAll("`", "\\`")}\`, ${inSVG}).firstChild; + function ${_}create($, ..._args) { + let ${node} = ${_}node.cloneNode(true); + ${beforeCreate} + ${$$.create} + ${node}[${updateKey}] = (..._args) => { + ${beforeUpdate} + ${$$.update} + return ${node}; + }; + return ${node}; + }\n`; +} + +export function generateVnode(vnode, $) { + for (const [predicate, macro] of macros) { + const kv = Object.entries(vnode.properties).find(([k]) => predicate.test(k)); + if (kv) { + delete vnode.properties[kv[0]]; + return void macro(vnode, $, ...kv); + } + } + generateNodeRef($, vnode, "vnode"); + const [tag, rawTag, isDynamicTag] = parseValue(vnode.tag); + if (vnode.ref === "this") { + generateProperties(vnode, $); + generateChildren(vnode, $); + } else if (!isDynamicTag && !isComponentTag(rawTag)) { + $.html += `<${rawTag}`; + generateProperties(vnode, $); + $.html += ">"; + if (vnode.void) return; + generateChildren(vnode, $); + $.html += ``; + } else if (isComponentTag(rawTag)) { + const [splats, attrs, kvs] = Object.entries(vnode.properties).reduce(([splats, attrs, kvs], [k, v]) => { + if (splatRegexp.test(k)) return [splats.concat(k.match(splatRegexp)[1]), attrs, kvs]; + else if (k === "id" || k === "class") return [splats, attrs.concat(`${k}="${v}"`), kvs]; + return [splats, attrs, kvs.concat(`[${parseValue(k)[0]}]: ${parseValue(v)[0]}`)]; + }, [[], [], []]); + $.html += `<${rawTag}${attrs.length ? " " + attrs.join(" ") : ""}>`; + generateChildren(vnode, $); + $.html += ``; + const props = splats.length ? `Object.assign({}, ${splats.join(", ")}, {${kvs.join(", ")}})` : `{${kvs.join(", ")}}`; + $.create += `${vnode.ref}.init($.app, $, ${props});\n`; + $.update += `${vnode.ref}.update(${props});\n`; + } else { + throw new Error("dynamic tags are currently not supported"); + } +} + +function generateProperties(vnode, $) { + const dynamicProperties = []; + for (const k in vnode.properties) { + const [key, rawKey, isDynamicKey] = parseValue(k), + [value, rawValue, isDynamicValue] = parseValue(vnode.properties[k]); + if (splatRegexp.test(k)) throw new Error(`splat properties are only allowed on component tags, not <${vnode.tag}>`); + else if (!isDynamicKey && !isDynamicValue && vnode.ref === "this") $.create += `xm.setTemplateProperty(${vnode.ref}, ${key}, ${value});\n`; + else if (!isDynamicKey && !isDynamicValue) $.html += ` ${rawKey}=${value}`; + else dynamicProperties.push({key, value, isDynamicKey, isDynamicValue}); + } + if (!dynamicProperties.length) return; + const node = generateLocalNodeRef($, vnode, "properties"); + for (let {key, value, isDynamicKey, isDynamicValue} of dynamicProperties) { + if (!isDynamicKey) { + $.create += `xm.setProperty(${node}, ${key}, ${value});\n`; + $.update += `xm.setProperty(${node}, ${key}, ${value});\n`; + } else { + const _ = prefix("properties"); + $.create += `let ${_}key = ${key}; xm.setProperty(${node}, ${_}key, ${value});\n`; + $.update += `${_}key = xm.setDynamicKeyProperty(${node}, ${_}key, ${key}, ${value});\n`; + } + } +} + +function generateChildren(vnode, $) { + let node = vnode.ref + ".firstChild", dynamicChildren = []; + for (const vchild of vnode.children) { + node = generateNodeRef($, {ref: node}, "children"); + if (vchild.tag) { + vchild.ref = node; + generateVnode(vchild, $); + node = vchild.ref; + node = node + ".nextSibling"; + } else { + for (let [value, rawValue, isDynamic] of parseValueParts(vchild)[0]) { + $.html += isDynamic ? "" : rawValue; + if (isDynamic) dynamicChildren.push([node, value]); + node = node + ".nextSibling"; + } + } + } + if (dynamicChildren.length) { + const _ = prefix("children"), values = dynamicChildren.map(([_, v]) => v), nodes = dynamicChildren.map(([n]) => n), + node = generateLocalNodeRef($, vnode, "children"); + + $.create += `const ${_}nodes = [${nodes}], ${_}values = []; + xm.updateChildNodes(${node}, null, ${_}nodes, ${_}values, [${values}], $, xm.createChildNode);\n`; + $.update += `xm.updateChildNodes(${node}, null, ${_}nodes, ${_}values, [${values}], $, xm.createChildNode);\n`; + } +} + +function isComponentTag(tag) { + return tag && tag.startsWith("x-"); +} + +function generateNodeRef($, vnode, key = "") { + if (vnode.ref.indexOf(".") === -1) return vnode.ref; + return vnode.ref = generateLocalNodeRef($, vnode, key); +} + +function generateLocalNodeRef($, vnode, key = "") { + const _ = prefix(key); + $.create += `let ${_}node = ${vnode.ref};\n`; + return `${_}node`; +} + +let prefixId = 0; +export const resetPrefixId = () => prefixId = 0; +const prefix = (key) => `_${key}_${prefixId++}_`; diff --git a/docs/src/dev.mjs b/docs/src/dev.mjs new file mode 100644 index 0000000..865cbe8 --- /dev/null +++ b/docs/src/dev.mjs @@ -0,0 +1,48 @@ +export function fluidScale([screenMin, ratioMin], [screenMax, ratioMax], n, rem) { + const vwMin = screenMin / 100, vwMax = screenMax / 100; + return [...Array(n)].map((_, x) => { + // vwPix is what changes, so it's our x, making targetPx y. + // we want to linearly interpolate between the targetPx values - i.e. targetPx = y+b*vwPx + // b is how much targetPx changes relative to vwPx - i.e. the ratio of the diffs + // y is the targetPx value we want at x=0. As we want remMin at screenMin/vwMin we have to + // subtract b*vwPxMin while correcting for the px/rem ratio + const i = Math.ceil(-n/2) + x; + let remMin = ratioMin**i, remMax = ratioMax**i; + const targetPxDiff = (remMax - remMin) * rem, vwPxDiff = vwMax - vwMin; + const b = targetPxDiff / vwPxDiff, y = remMin - ((b * vwMin) / rem); + if (i === 0) return "--r0: 1rem;"; + if (remMin > remMax) [remMin, remMax] = [remMax, remMin]; + return `--r${i}: clamp(${remMin.toFixed(2)}rem, ${y.toFixed(2)}rem + ${b.toFixed(2)}vw, ${remMax.toFixed(2)}rem);` + }).join("\n"); +} + +export function imgUrl(width = 500, height = 500, txt = `${width}x${height}`) { + const svg = ` + + + ${txt} + `; + return `data:image/svg+xml;charset=utf8,${svg}`; +} + +export function lorem(i = 0, j = 1) { + return loremIpsum.split("\n\n").slice(i, j).join("\n\n"); +} + +// https://la.wikisource.org/wiki/Lorem_ipsum +const loremIpsum = ` +Lorem ipsum dolor sit amet, consectetur adipisici elit, sed eiusmod tempor incidunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquid ex ea commodi consequat. Quis aute iure reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint obcaecat cupiditat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. + +Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis at vero eros et accumsan et iusto odio dignissim qui blandit praesent luptatum zzril delenit augue duis dolore te feugait nulla facilisi. + +Nam liber tempor cum soluta nobis eleifend option congue nihil imperdiet doming id quod mazim placerat facer possim assum. Lorem ipsum dolor sit amet, consectetuer adipiscing elit, sed diam nonummy nibh euismod tincidunt ut laoreet dolore magna aliquam erat volutpat. Ut wisi enim ad minim veniam, quis nostrud exerci tation ullamcorper suscipit lobortis nisl ut aliquip ex ea commodo consequat. + +Duis autem vel eum iriure dolor in hendrerit in vulputate velit esse molestie consequat, vel illum dolore eu feugiat nulla facilisis. + +At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, At accusam aliquyam diam diam dolore dolores duo eirmod eos erat, et nonumy sed tempor et et invidunt justo labore Stet clita ea et gubergren, kasd magna no rebum. sanctus sea sed takimata ut vero voluptua. est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat. + +Consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.`; diff --git a/docs/src/jsxy.mjs b/docs/src/jsxy.mjs new file mode 100644 index 0000000..e4db761 --- /dev/null +++ b/docs/src/jsxy.mjs @@ -0,0 +1,372 @@ +const attrs = new Set("list", "form", "selected"), subs = new Map(); +let hooks, hookKey, hookIndex, oldSearch, oldHash, style; + +export const directives = { + store: applyStoreDirective, + href: applyHrefDirective, + intersect: applyIntersectDirective, +} + +export const db = new Proxy(localStorage, { + get: (t, k) => JSON.parse(t.getItem(k)), + set: (t, k, v) => { + t.setItem(k, JSON.stringify(v)); + if (!publish.active) publish(db, k, v); + return true; + }, + deleteProperty: (t, k) => (t.removeItem(k), true), + ownKeys: (t) => Reflect.ownKeys(t), + getOwnPropertyDescriptor: (t, k) => Reflect.getOwnPropertyDescriptor(t, k), +}); + +export const query = new Proxy(searchParams, { + get: (t, k) => { + const v = t()[1].get(k); + return v && (v[0] === "[" || v[0] === "{") ? JSON.parse(v) : v; + }, + set: (t, k, v) => { + const [path, q] = t(), sv = Object(v) === v ? JSON.stringify(v) : v; + q[sv != null && sv !== "" ? "set" : "delete"](k, sv); + history.replaceState(null, "", "?"+path+(q.size ? "&"+q : "")+location.hash); + if (!publish.active) publish(query, k, v); + return true; + }, + deleteProperty: (t, k) => (query[k] = undefined, true), + ownKeys: (t) => [...t().keys()], + getOwnPropertyDescriptor: (t, k) => ({enumerable: 1, configurable: 1}), +}); + +export function sub(store, k, f) { + if (!subs.has(store)) subs.set(store, {}); + subs.get(store)[k] = [f].concat(subs.get(store)[k] || []); + return () => subs.get(store)[k] = subs.get(store)[k].filter(x => x !== f); +} + +function publish(store, k, v) { + publish.active = true; + if (subs.get(store) && k in subs.get(store)) for (let f of subs.get(store)[k]) f(v); + delete publish.active; +} + +export function html(strings, ...values) { + let $ = "child", xs = [{children: []}], tmp = ""; + for (let s of strings) { + for (let i = 0, c = s[0]; i < s.length; i++, c = s[i]) { + if ($ === "child" && c === "<") { + if (tmp.trim() !== "") { + xs[xs.length-1].children.push(tmp); + } + if (s[i+1] === "/") { + $ = "close"; + } else { + xs.push({children: [], props: []}); + $ = "#$:.-".includes(s[i+1]) ? "key" : "open"; + } + } else if (($ === "close" && c === ">")) { + const x = xs.pop(), props = x.props.length || typeof x.tag === "function" ? {} : undefined; + if (!("tag" in x)) x.tag = "div"; + if (tmp && tmp !== "/" && tmp.slice(1) !== x.tag) { + throw new Error(`unexpected <${tmp}> in <${x.tag}>`); + } + for (let i = 0; i < x.props.length; i += 2) { + const k = x.props[i], v = x.props[i+1]; + if (k === "...") Object.assign(props, v); + else if (k[0] === ".") v && (props.classList = (props.classList || "") + " " + k.slice(1)); + else if (k[0] === "#") v && (props.id = k.slice(1)); + else if (k[0] === "$") v && (x.ref = k.slice(1)); + else if (k[0] === "-" && k[1] === "-") props.style = (props.style || "") + `;${k}:${v};`; + else if (k[0] === ":") x.dirs = {...x.dirs, [k]: v} + else props[k] = v; + } + x.props = props; + xs[xs.length-1].children.push(x); + $ = "child"; + } else if (($ === "open" || $ === "key" || $ === "value") && + (c === " " || c === "\n" || c === ">" || (c === "/" && s[i+1] === ">"))) { + if ($ === "open") { + xs[xs.length-1].tag = tmp; + } else if ($ === "key" && tmp) { + xs[xs.length-1].props.push(tmp, true); + } else if ($ === "value") { + xs[xs.length-1].props.push(tmp); + } + $ = c === "/" ? "close" : c === ">" ? "child" : "key"; + } else if ($ === "key" && c === "=") { + xs[xs.length-1].props.push(tmp); + $ = s[i+1] === `'` || s[i+1] === `"` ? "quoted-value" : "value"; + } else if ($ === "quoted-value" && (c === tmp[0])) { + xs[xs.length-1].props.push(tmp.slice(1)); + $ = "key"; + } else { + tmp += c; + continue + } + tmp = ""; + } + + let v = values.shift(); + if ($ !== "child" && v != null && v !== false) { + tmp = tmp ? tmp + v : v; + } else if ($ === "child") { + if (tmp.trim() || values.length) { + xs[xs.length-1].children.push(tmp); + tmp = ""; + } + if (Array.isArray(v)) xs[xs.length-1].children.push(...v); + else if (v != null && v !== false) xs[xs.length-1].children.push(v); + } + } + + const children = xs.pop().children; + if (tmp.trim()) { + throw new Error(`leftovers: '${tmp}'`); + } else if (xs.length) { + throw new Error(`leftovers: ${JSON.stringify(xs)}`); + } else if (children.length > 1) { + throw new Error (`more than one top lvl node: ${JSON.stringify(children)}`) + } + return children[0]; +} + +export function css(strings, ...values) { + if (!style) style = document.head.appendChild(document.createElement("style")); + style.innerHTML += String.raw(strings, ...values); +} + +export function useState(initialValue) { + return getHook({value: initialValue}).value; +} + +export function useEffect(mount, args = []) { + const hook = getHook({}); + hook.changed = !hook.args || hook.args.some((a, i) => a !== args[i]); + hook.args = args, hook.mount = mount; +} + +export function getHook(v) { + if (!hookKey) throw new Error(`getting hook from unkeyed component`); + if (!hooks[hookKey]) hooks[hookKey] = []; + let keyHooks = hooks[hookKey], hook = keyHooks[hookIndex++]; + return hook ? hook : keyHooks[keyHooks.push(v) - 1]; +} + +export function render(vnode, parentNode) { + if (parentNode) return void renderChildren(parentNode, vnode, vnode); + vnode = vnode.self || vnode.component || vnode; + hooks = vnode.node.parentNode.hooks; + renderChild(vnode.node.parentNode, vnode, vnode.node, vnode); +} + +function renderChildren(parentNode, vnodes, component, ns) { + if (!Array.isArray(vnodes)) vnodes = [vnodes]; + let oldHooks = parentNode.hooks || {}, newHooks = {}, nodes = [...parentNode.childNodes]; + hooks = oldHooks, parentNode.hooks = newHooks; + for (let i = 0; i < vnodes.length; i++) { + renderChild(parentNode, vnodes[i], nodes[i], component, ns); + } + for (let k in oldHooks) { + if (!(k in newHooks)) for (let h of oldHooks[k]) h.unmount?.(); + } + for (let n = parentNode.childNodes.length - vnodes.length; n > 0; n--) { + unmount(parentNode.lastChild).remove(); + } +} + +function renderChild(parentNode, vnode, node, component, ns) { + if (vnode == null) return node ? void unmount(node).remove() : null; + if (!vnode.tag) return createTextNode(parentNode, vnode, node); + if (typeof vnode.tag !== "function") { + ns = vnode.props?.xmlns || ns + if (!node || vnode.tag !== node.vnode?.tag) node = createNode(parentNode, vnode.tag, node, ns); + if (vnode.ref) component.props.$[vnode.ref] = node; + if (vnode.props || node.vnode?.props) setProperties(node, vnode, component, ns); + vnode.node = node, node.vnode = vnode, node.component = component; + renderChildren(node, vnode.children, component, ns); + if (vnode.dirs) applyDirectives(node, vnode) + return node; + } + vnode.props.$ = {self: vnode, app: component.props?.$?.app || component}; + hookIndex = 0, hookKey = vnode.props.key || vnode.props.id; + const _hooks = hooks, _vnode = vnode.tag(vnode.props), _vnodeHooks = _hooks[vnode.props.key]; + node = vnode.node = renderChild(parentNode, _vnode, node, vnode, ns), hooks = _hooks; + if (_vnodeHooks) { + parentNode.hooks[vnode.props.key] = _vnodeHooks; + for (let h of _vnodeHooks) { + if (h.mount && (h.changed || h.node !== node)) { + if (h.unmount) h.unmount(); + h.unmount = h.mount(node), h.node = node, h.changed = false; + } + } + } + if (vnode.ref) component.props.$[vnode.ref] = node; + return node; +} + +function unmount(node) { + for (let k in node.hooks) { + for (let h of node.hooks[k]) h.unmount?.(); + } + for (let child of node.childNodes) unmount(child); + return node; +} + +function createNode(parentNode, tag, node, ns) { + const newNode = ns ? document.createElementNS(ns, tag) : document.createElement(tag); + return replaceNode(parentNode, newNode, node); +} + +function createTextNode(parentNode, vnode, node) { + if (node?.nodeType === 3) node.data = vnode; + else node = replaceNode(parentNode, document.createTextNode(vnode), node); + return node; +} + +function replaceNode(parentNode, newNode, oldNode) { + if (oldNode) unmount(oldNode), parentNode.replaceChild(newNode, oldNode); + else parentNode.append(newNode); + return newNode; +} + +function setProperties(node, vnode, component, ns) { + for (let k in vnode.props) { + if (node.vnode?.props?.[k] !== vnode.props[k]) setProperty(node, k, vnode.props[k], ns); + } + if (node.vnode) { + for (let k in node.vnode.props) { + if (!vnode.props || !(k in vnode.props)) setProperty(node, k, ""); + } + } +} + +function setProperty(node, k, v, ns) { + if (k[0] == "o" && k [1] == "n") setEventListener(node, k.slice(2), eventListener, eventListener); + else if (k[0] === "@") setEventListener(node, k.slice(1), eventListener, eventListener); + else if (k in node && !attrs.has(k) && !ns) node[k] = v == null ? "" : v; + else if (v == null || v === false) node.removeAttribute(k); + else node.setAttribute(k, v); +} + +function setEventListener(node, type, f, g) { + if (g) node.removeEventListener(type, g); + if (f) node.addEventListener(type, f); +} + +function eventListener(e) { + const props = this.vnode.props; + const v = props["on"+e.type] || props["@"+e.type]; + if (Array.isArray(v)) v[0](e, ...v.slice(1)); + else v(e); + if (props["@"+e.type]) render(e.target.component); +} + +function applyDirectives(node, vnode) { + for (const dk in vnode.dirs) { + const [_, k, ...args] = dk.split(":"), f = directives[k], v = vnode.dirs[dk]; + if (f) node.dataset[k] = f(node, vnode, args, v, node.dataset[k]); + } +} + +function applyStoreDirective(node, {tag, props}, [type, key], v, data) { + const store = {query, db}[type]; + if (tag !== "form") throw new Error(`:store on non-form tag '${tag}'`) + else if (!key || !store) throw new Error("bad key or type in :store::"); + if (!data) { + node.addEventListener("submit", (e) => e.preventDefault()); + node.addEventListener("reset", (e) => delete store[key]); + node.addEventListener("input", (e) => { + const fd = new FormData(node), m = {}; + iterateForm(node, (k, el, k2) => { + const vs = fd.getAll(k); + if (!k2) m[k] = vs[0]; + else if (vs.length) m[k] = Object.fromEntries(vs.map(k => [k, true])); + }); + store[key] = m; + }); + } + const m = store[key]; + iterateForm(node, (k, el, k2) => { + const v = m && m[k]; + if (k2) for (const x of el) x[k2] = v && v[x.value]; + else if (el.type === "checkbox") el.checked = v; + else el.value = v == null ? "" : v; + }); + return true; +} + +function applyIntersectDirective(node, {tag, props}, args, rootMargin = "0% 100%", data) { + if (!data) { + const observer = new IntersectionObserver((xs, observer) => { + for (const x of xs) x.target.classList.toggle("intersecting", x.isIntersecting); + }, {rootMargin}); + observer.observe(node); + } + return true; +} + +function applyHrefDirective(node, {tag, props}, args, href, data) { + node.href = href; + if (!data) node.addEventListener("click", (e) => (route.go(node.href), e.preventDefault())); + return true; +} + +function iterateForm(form, f) { + for (let k of new Set([...form.elements].map(el => el.name).filter(Boolean))) { + const el = form.elements[k]; + if (!el.multiple && !(!el.type && el[0].type === "checkbox")) f(k, el); + else f(k, (el.options || el), el.options ? "selected" : "checked"); + } +} + +export function route(routes, parentNode) { + route.go = (href) => (history.pushState({}, "", href), renderRoute(routes, parentNode)); + window.addEventListener("popstate", () => renderRoute(routes, parentNode)); + renderRoute(routes, parentNode); +} + +function renderRoute(routes, parentNode) { + const [path, q] = searchParams(), params = Object.fromEntries(q); + if (!(location.hash !== oldHash && location.search === oldSearch)) { + for (let [r, tag] of Object.entries(routes)) { + if (matchRoute(r, path, params)) { + if (location.search !== oldSearch) { + window.scrollTo(0, 0); + document.activeElement?.blur?.(); + oldSearch = location.search; + } + Object.assign(route, {path, params}); + render({tag, props: params}, parentNode); + oldSearch = location.search; + } + } + } + if (location.search !== oldSearch) return void route.go("?/"); + dispatchHashEvent(oldHash, "leave"); + dispatchHashEvent(location.hash, "enter"); + oldHash = location.hash; +} + +function matchRoute(route, path, params) { + const r = new RegExp("^" + route.replace(/\/?$/, "/?$").replace(/\/{(.+?)}/g, (_, x) => + x.startsWith("...") ? `(?<${x.slice(3)}>(/.*)?)` : `/(?<${x}>[^/]+)` + )); + const match = r.exec(path); + if (match) { + params.key = route; + for (const k in match.groups) params[k] = decodeURIComponent(match.groups[k]); + return true; + } +} + +function dispatchHashEvent(hash = "", type) { + const [id, ...args] = hash.split(":"), el = id && document.querySelector(id); + if (el) { + void el.offsetWidth; // reflow + el.classList.toggle("target", type === "enter"); + el.dispatchEvent(new CustomEvent("hash", {detail: {id, args, type}})); + } +} + +function searchParams() { + const q = location.search, [path] = q.slice(1).split("&", 1); + return [path, new URLSearchParams(q.slice(path.length+1))]; +} diff --git a/docs/src/parser.mjs b/docs/src/parser.mjs new file mode 100644 index 0000000..9b48689 --- /dev/null +++ b/docs/src/parser.mjs @@ -0,0 +1,101 @@ +// https://html.spec.whatwg.org/multipage/syntax.html#void-elements +const voidTags = {area: 1, base: 1, br: 1, col: 1, embed: 1, hr: 1, img: 1, input: 1, link: 1, meta: 1, param: 1, source: 1, track: 1, wbr: 1}; +const badAttributeRegexp = /^({=?})$|[^=]=""|^{[^}]*$|^[^{]*}$/; + +export function parseValue(input) { + const [parts, isDynamic] = parseValueParts(input); + return [parts.length === 1 ? parts[0][0] : parts.map(p => p[0]).join(" + "), + isDynamic ? null : parts[0][1], + isDynamic]; +} + +export function parseValueParts(input) { + let parts = [], push = (lvl, part) => { + if (part) parts.push([lvl === 1 ? `(${part})` : `"${part}"`, part, lvl === 1]); + }; + lexValue(input, push); + return [parts, parts.some(p => p[2])]; +} + +export function lexValue(input, push, count = Infinity) { + let part = "", lvl = 0; + for (let c of input) { + if (c === "{") { + if (lvl === 0) push(lvl, part), part = "", count--; + else part += c; + lvl++; + } else if (c === "}") { + if (lvl === 1) push(lvl, part), part = "", count--; + else part += c; + lvl--; + } else { + part +=c; + } + if (!count) return; + } + push(lvl, part); +} + +export function parse(template) { + const tokens = lex(template), vnodes = [], parents = []; + for (let i = 0, [$, x] = tokens[0]; i < tokens.length; i++, [$, x] = tokens[i] || []) { + if ($ === "open") { + const vchild = {tag: x, properties: {}, children: [], parent: parents[0], void: !!voidTags[x]}; + (parents[0]?.children || vnodes).push(vchild); + parents.unshift(vchild); + } else if ($ === "close") { + const vnode = parents.shift(); + if (vnode && x !== "/" && x !== "/" + vnode.tag) throw new Error(`unexpected close ${x} for <${vnode.tag}>`); + } else if ($ === "child") { + (parents[0]?.children || vnodes).push(x); + } else if ($ === "key") { + let k = x, v = tokens[i+1][0] === "value" ? tokens[++i][1] : "true"; + if (badAttributeRegexp.test(k)) throw new Error(`unexpected attribute key '${k}' for <${parents[0].tag}>`); + if (badAttributeRegexp.test(v)) throw new Error(`unexpected attribute value '${v}' for <${parents[0].tag}>`); + parents[0].properties[k] = v; + } else throw "unexpected: " + $; + } + if (parents.length) throw new Error(`unclosed ${parents[0].tag}: ${template}`); + return vnodes; +} + +export function lex(template) { + let $ = "child", tag = "", tmp = "", tokens = [], push = ($next) => { + if (tmp) tokens.push([$, tmp.trim() ? tmp : "\n"]); + if ($ === "open") tag = tmp; + else if ($ === "close") tag = ""; + $ = $next, tmp = ""; + }; + for (let i = 0, c = template[0]; i < template.length; i++, c = template[i]) { + if (c === "{") { + lexValue(template.slice(i), (lvl, part) => { + if (lvl) tmp += `{${part}}`, i += part.length + 1; + }, 2); + } else if ($ === "child" && c === "<") { + if (template.slice(i+1, i+4) === "!--") i = skipComment(template, i); + else push(template[i+1] === "/" ? "close" : "open"); + } else if ($ !== "child" && (c === ">" || c === "/" && template[i+1] === ">")) { + push("child"); + if (c === "/") i++; + if (c === "/" || voidTags[tag]) tokens.push(["close", "/"]); + } else if ($ !== "child" && (c === " " || c === "\n")) { + push("key"); + } else if ($ === "value" && (c === "'" || c === '"')) { + while (template[++i] !== c) tmp += template[i]; + tmp = tmp || "true"; + push("key"); + } else if ($ === "key" && c === "=") { + push("value"); + } else { + tmp += c; + } + } + push(); + return tokens; +} + +function skipComment(template, i) { + const j = template.indexOf("-->", i); + if (j === -1) throw new Error(`unclosed comment: ${template.slice(i, i+20)}[...]`); + return j + 2; +} diff --git a/docs/src/runtime.mjs b/docs/src/runtime.mjs new file mode 100644 index 0000000..13623d6 --- /dev/null +++ b/docs/src/runtime.mjs @@ -0,0 +1,166 @@ +import * as exports from "./runtime.mjs"; + +const classes = {}, compilerTemplate = document.createElement("template"); + +export const symbols = { + updateChildNode: Symbol("updateChildNode"), + updateIfNode: Symbol("updateIfNode"), + updateComponent: Symbol("updateComponent"), + parentComponent: Symbol("parentComponent"), +}; + +export const ready = init(); + +async function init() { + window.xm = exports; + if (document.querySelector("[type*=x-module], [type*=x-template]")) { + if (document.body.hasAttribute("x-dev")) document.querySelectorAll("[x-dev]").forEach((el) => el.removeAttribute("x-dev")); + const {bundle} = await import("./bundler.mjs"); + await import(await bundle(location, null)); + } + const xMount = document.querySelector("[type*=x-mount]"); + if (xMount) window.app = mount(xMount.parentNode, xMount.innerHTML, await window.props); + await new Promise(setTimeout); +} + +export function setProperty(node, k, v) { + const inSVG = node.namespaceURI === "http://www.w3.org/2000/svg"; + if (k in node && k !== "list" && k !== "form" && k !== "selected" && !inSVG) node[k] = v == null ? "" : v; + else if (k.startsWith("_")) node[k] = v == null ? "" : v; + else if (v == null || v === false) node.removeAttribute(k); + else node.setAttribute(k, v); +} + +export function setDynamicKeyProperty(node, k, updatedK, v) { + if (k !== updatedK) setProperty(node, k, null); + setProperty(node, updatedK, v); + return updatedK; +} + +export function setTemplateProperty(node, k, v) { + if (k === "class") node.classList.add(...v.split(" ")); + else setProperty(node, k, v); +} + +export function replaceWith(oldNode, newNode) { + oldNode.replaceWith(newNode); + return newNode; +} + +export function nodeIf(condition, connectedNode, elseNode, $, create, update) { + if (!condition) { + if (connectedNode === elseNode) return elseNode; + return replaceWith(connectedNode, elseNode); + } else if (condition && connectedNode !== elseNode) { + connectedNode[symbols.updateIfNode](); + return connectedNode; + } + return replaceWith(elseNode, create($)); +} + +export function createChildNode($, value) { + let node = value, oldValue; + if (value instanceof DocumentFragment) throw new Error("Cannot use DocumentFragment as child"); + else if (value instanceof Node) return value; + node = document.createTextNode(value == null || value === false ? "" : value); + node[symbols.updateChildNode] = (updatedValue) => { + oldValue = value, value = updatedValue; + if (oldValue === updatedValue) return node; + else if ((updatedValue instanceof Node)) return updatedValue; + node.textContent = value == null || value === false ? "" : value; + return node; + }; + return node; +} + +export function updateChildNodes(parent, anchor, nodes, values, updatedValues, $, create) { + for (let i = updatedValues.length; i < values.length; i++) nodes[i].remove(); + values.length = updatedValues.length, nodes.length = updatedValues.length; + for (let i = 0; i < updatedValues.length; i++) { + if (!nodes[i]) { + const node = create($, updatedValues[i]); + parent.insertBefore(node, anchor); + nodes[i] = node; + } else if (nodes[i] !== updatedValues[i]) { + let oldNode = nodes[i], updatedValue = updatedValues[i]; + nodes[i] = oldNode[symbols.updateChildNode] ? oldNode[symbols.updateChildNode](updatedValue) : create($, updatedValue); + if (oldNode !== nodes[i]) replaceWith(oldNode, nodes[i]); + } + values[i] = updatedValues[i]; + } +} + +export async function mount(parentNode, name, props = {}) { + await ready; + let app; + if (name.trim()[0] !== "<") app = document.createElement(name); + else { + const {compile} = await import("./compiler.mjs"); + eval(compile("x-mount", `${name}`)); + app = document.createElement("x-mount"); + app.style.display = "contents"; + } + app.init(app, app, props); + return parentNode.appendChild(app); +} + +export function inject(component, tag) { + const parent = component[symbols.parentComponent]; + if (parent && parent.tagName === tag) return parent; + else if (parent) return inject(parent, tag); +} + +export function fragment(html, inSVG) { + compilerTemplate.innerHTML = inSVG ? `${html}` : html; + return document.importNode(inSVG ? compilerTemplate.content.firstChild : compilerTemplate.content, true); +} + +export function define(name, c) { + if (!(c.prototype instanceof Component)) throw new Error("class must inherit from Component"); + classes[name] = c; +} + +export function register(name, html, f, assignedProps) { + const Class = (classes[name] || Component); + const handlers = Object.getOwnPropertyNames(Class.prototype).filter(k => k !== "constructor" && k in window); + const template = document.createElement("template"); + template.innerHTML = `
`; + const slotTemplate = template.content.firstChild; + template.innerHTML = html; + customElements.define(name, class extends Class { + init(app, parent, props) { + this[symbols.parentComponent] = parent; + this.app = app; + this.props = props; + this.slots = {rest: slotTemplate.cloneNode(true)}; + while (this.firstChild) { + const slot = this.firstChild.getAttribute?.("x-slot"); + if (slot) this.slots[slot] = this.removeChild(this.firstChild); + else this.slots.rest.append(this.firstChild); + } + for (const k of handlers) this.addEventListener(k.slice(2), (e) => void this[k](e)); + for (let k of assignedProps) this[k] = this.props[k]; + this.onInit(this.props); + this.onRender(this.props); + this.append(document.importNode(template.content, true)); + this[symbols.updateComponent] = f.call(this); + } + disconnectedCallback() { + this.onRemove(this.props); + } + update(props = this.props) { + this.props = props; + for (let k of assignedProps) this[k] = this.props[k]; + this.onUpdate(this.props); + this.onRender(this.props); + this[symbols.updateComponent](); + } + }); +} + +export class Component extends HTMLElement { + onInit(props) {} + onUpdate(props) {} + onRender(props) {} + onRemove(props) {} +} diff --git a/docs/src/test.mjs b/docs/src/test.mjs new file mode 100644 index 0000000..4aeced1 --- /dev/null +++ b/docs/src/test.mjs @@ -0,0 +1,306 @@ +import * as exports from './test.mjs'; + +let count = 0, countFailed = 0, + dynamicOnly = false, exitAfter = false, + resolve = null; +export const root = newNode(), + updateFixtures = window.args?.includes("update-fixtures"), + fixtures = {}, + done = new Promise((r) => resolve = r); +export let current = {node: root}; + +export function t(name, f) { + beforeCreate("t", name, f); + current.node.children.push({name, f, selected: current.node.selected}); +} + +Object.assign(t, { + describe(name, f) { + beforeCreate("t.describe", name, f); + group(name, f); + }, + + describeOnly(name, f) { + beforeCreate("t.describeOnly", name, f); + group(name, f, true); + markNodes(current.node, "hasSelected"); + if (count || countFailed) dynamicOnly = true; + }, + + only(name, f) { + beforeCreate("t.only", name, f); + current.node.children.push({name, f, selected: true}); + markNodes(current.node, "hasSelected"); + if (count || countFailed) dynamicOnly = true; + }, + + exitAfter() { + exitAfter = true; + }, + + beforeEach(name, f) { + wrapper(name, f, "beforeEach"); + }, + + afterEach(name, f) { + wrapper(name, f, "afterEach"); + }, + + before(name, f) { + wrapper(name, f, "before"); + }, + + after(name, f) { + wrapper(name, f, "after"); + }, + + throws(f, regexp, msg) { + throws(f, regexp, msg, true); + }, + + rejects(f, regexp, msg) { + return throws(f, regexp, msg); + }, + + assert(x, msg) { + if (!x) t.fail(msg, `${x} == true`); + }, + + equal(x, y, msg) { + if (x !== y) t.fail(msg, `${x} === ${y}`); + }, + + jsonEqual(x, y, msg) { + x = json(x), y = json(y); + if (x !== y) t.fail(msg, `${x} !== ${y}`); + }, + + assertFixture(actual, msg) { + const {fixtures, updateFixtures, current} = window.test; + const id = `${current.id} (${current.assertFixtureCalls})`; + current.assertFixtureCalls++; + if (!updateFixtures) t.jsonEqual(actual, fixtures[current.node.fixtureUrl][id], id); + else { + fixtures[current.node.fixtureUrl] = fixtures[current.node.fixtureUrl] || {}; + if (fixtures[current.node.fixtureUrl][id]) t.fail(`reassignment of fixture "${id}"`); + fixtures[current.node.fixtureUrl][id] = actual; + } + }, + + bench(name, f, maxDuration = 1000) { + current.node.children.push({name, f: f && (() => { + let ts = [maxDuration], m = 1, n = 1, d = 0, i = 0; + while (n < 1e9 && d < maxDuration) { + // n to fill remaining time based on previous t/op. limit to at most 100x previous n / 1e9 total + n = Math.min(Math.min((maxDuration - d) / ts[ts.length-1], m*10), 1e9); + const start = performance.now(); + f(n); + const elapsed = performance.now() - start; + m = n, d += elapsed, i += m, ts.push(elapsed / n); + } + ts = ts.length > 2 ? ts.slice(2) : ts.slice(1); + let avgT = ts.reduce((sum, t) => sum + t, 0) / ts.length, unit = "ms"; + if (avgT < 1) avgT = avgT * 1000, unit = "µs"; + t.log(`${avgT.toFixed(2)}${unit}/op\t${i.toLocaleString()} ops`, "grey"); + })}); + }, + + fail(msg, info = "fail") { + throw new Error(`${msg ? msg + ": " : ""}${info}`); + }, + + log(msg, color = "grey") { + current.logs.push(...msg.split("\n").map(line => [line, color])); + } +}); + +function throws(f, regexp = /.*/, msg, sync) { + if (typeof f !== 'function') t.fail(`expected ${f} to be a function`); + if (typeof regexp === 'string') msg = regexp, regexp = /.*/; + let result = null, checkError = (err) => { + if (!err) t.fail(msg, `expected ${f} to throw`); + else if (!regexp.test(err.message)) t.fail(`expected ${f} to throw ${regexp}`, err.message); + }; + try { + result = f(); + if (!sync && result instanceof Promise) return result.then(checkError, checkError); + } catch (err) { + return void checkError(err); + } + if (result) t.fail(`${f} must not return a value`); + checkError(null); +} + +function wrapper(name, f, key) { + if (!f) f = name, name = `${key} ${current.node[key + "s"].length}`; + current.node[key + "s"].push({name, f}); +} + +function getEachWrappers(node) { + const befores = [], afters = []; + do { + befores.push(...node.beforeEachs.slice().reverse()); + afters.push(...node.afterEachs.slice().reverse()); + } while (node = node.parent); + return [befores.reverse(), afters.reverse()]; +} + +function group(name, f, selected) { + current.node = newNode(name, current.node, selected); + const result = f?.(); + if (result) throw new Error(`unexpected return value from describe: ${result}`); + current.node.parent.children.push(current.node); + current.node = current.node.parent; +} + +async function run(lvl, node) { + current.node = null; + const time = timer(), selected = !root.hasSelected || node.selected || node.hasSelected; + if (selected && node !== window.test.root) log(lvl, 0, "", node.name); + await loadFixtures(node); + for (let {name, f} of node.befores) await runWrapper(lvl+2, node, name, f, selected); + for (let child of node.children) { + if (child.children) await run(lvl+2, child); + else await runTest(lvl+2, node, child); + } + for (let {name, f} of node.afters) await runWrapper(lvl+2, node, name, f, selected); + if (selected && node !== root) log(lvl, 0, "grey", `(${time()}ms)\n`); + else if (node === root) { + const details = dynamicOnly ? " - exit after dynamic only" : ""; + const color = countFailed ? "red" : dynamicOnly ? "yellow" : "grey"; + log(lvl+2, 0, color, `${count} tests (${countFailed} failures)${details}\n`); + } +} + +async function runWrapper(lvl, node, name, f, selected) { + if (!selected) return; + const [logs, ms, err] = await runFn(node, f, name); + if (err) log(lvl, 1, "red", `x ${name} (${ms}ms)`, err); + for (let [line, color] of logs) log(lvl+2, 1, color, line); +} + +async function runTest(lvl, node, {name, f, selected}) { + if (root.hasSelected && !selected) return; + count++; + const [beforeEachs, afterEachs] = getEachWrappers(node); + if (f) for (let {f, name} of beforeEachs) await runWrapper(lvl, node, name, f, true); + const [logs, ms, err] = await runFn(node, f, name); + if (f && !err) log(lvl, 0, "green", `✓ ${name} (${ms}ms)`); + else if (!err) log(lvl, 0, "yellow", `✓ ${name}`); + else { + log(lvl, 1, "red", `x ${name} (${ms}ms)`, err); + countFailed++; + } + for (let [line, color] of logs) log(lvl+2, 1, color, line); + if (f) for (let {f, name} of afterEachs) await runWrapper(lvl, node, name, f, true); +} + +async function runFn(node, f, name) { + const time = timer(), logs = []; + try { + current = getCurrent(node, name, logs); + const result = await Promise.race([f && f(), new Promise(r => setTimeout(() => r(node), 2000))]); + current.node = null; + if (result === node) t.fail("exceeded timeout of 2000ms"); + return [logs, time(), null]; + } catch (err) { + return [[], time(), err]; + } +} + +function getCurrent(node, name, logs) { + let id = name, n = node; + while (n.parent) id = `${n.name}: ${id}`, n = n.parent; + return {id, logs, node, assertFixtureCalls: 0}; +} + +function log(lvl, isFailure, color, line, err) { + console[navigator.webdriver ? "error" : "info"](" ".repeat(lvl) + "%c" + line, "color: " + color); + if (err) { + if (!navigator.webdriver) console.info(err); + else for (let l of err.stack.split("\n")) { + if (!l.includes(import.meta.url)) log(lvl+2, isFailure, "grey", l); + } + } +} + +function newNode(name, parent, selected) { + return {name, parent, selected, children: [], fixtureUrl: "", + befores: [], afters: [], + beforeEachs: [], afterEachs: []}; +} + +function markNodes(node, key) { + do { node[key] = true; } while (node = node.parent); +} + +function timer() { + const start = performance.now(); + return () => (performance.now() - start).toFixed(); +} + +function json(x, set = new WeakSet(), indent = 2) { + const _ = Function.prototype.toJSON; + Function.prototype.toJSON = function() { return `<<${this.toString()}>>` }; + const s = JSON.stringify(x, (k, v) => { + if (Object(v) === v) { + if (set.has(v)) return "[circular]"; + set.add(v); + } + return v; + }, indent); + Function.prototype.toJSON = _; + return s; +} + +async function loadFixtures(node) { + if (updateFixtures || window.test.fixtures[node.fixtureUrl]) return; + window.test.fixtures[node.fixtureUrl] = await fetch(node.fixtureUrl) + .then(r => r.json()) + .catch(() => ({})); +} + +function getTestUrl() { + const prepareStackTrace = Error.prepareStackTrace; + Error.prepareStackTrace = (err, stack) => stack; + const stack = new Error().stack; + Error.prepareStackTrace = prepareStackTrace; + const urls = typeof stack === "string" ? + stack.match(/http:.*:\d+:\d+$/mg).map(s => s.replace(/:\d+:\d+$/, "")) : + stack.map(f => f.getFileName()); + const testFile = urls.reverse().find(url => url !== import.meta.url); + if (!testFile) throw new Error("could not find test file name"); + return new URL(testFile).pathname.replace(/\/$/, "/index.html"); +} + +function getFixtureUrl() { + return getTestUrl().replace(/\/([^/]+)$/, "/fixtures/$1.json"); +} + +function beforeCreate(method, name, f) { + if (f && !(f instanceof Function)) throw new Error(`${method}("${name}") bad function body`); + if (!current.node.fixtureUrl) current.node.fixtureUrl = getFixtureUrl(); + if (!root.name) root.name = getTestUrl(); +} + +async function writeFixtures(fixtures) { + for (let [path, fixture] of Object.entries(fixtures)) { + await window.writeFile(path, json(fixture) + "\n"); + console.log(`Updated fixture ${path}`); + } +} + +async function init() { + const parentTest = window.parent.test; + window.test = parentTest || exports; + if (parentTest) parentTest.current.node.children.push(root); + else setTimeout(async () => { + if (window.isCI && root.hasSelected) throw new Error("only not allowed in CI"); + await run(0, root); + resolve({count, countFailed}); + if (updateFixtures) await writeFixtures(fixtures); + if (exitAfter) window.close(countFailed && 1); + }); +} + +init(); diff --git a/docs/src/util.mjs b/docs/src/util.mjs new file mode 100644 index 0000000..4cafcc6 --- /dev/null +++ b/docs/src/util.mjs @@ -0,0 +1,57 @@ +export function emitGestures(el = document, threshold = document.body.clientWidth / 5) { + let touches = new Map(), active = ""; + const event = (type, detail, bubbles = true, cancelable = true) => + new CustomEvent(type, {detail, bubbles, cancelable}); + + el.addEventListener("touchstart", e => { + if (e.touches.length !== 2 || active) return; + for (let t of e.touches) touches.set(t.identifier, t); + active = "gesture"; + }); + + el.addEventListener("touchend", e => { + for (let t of e.changedTouches) touches.delete(t.identifier); + if (touches.size === 0) { + if (active === "tap") { + e.changedTouches[0].target.dispatchEvent(event("tap")); + } + active = ""; + } + }); + + el.addEventListener("touchmove", e => { + if (touches.size !== 2) return; + e.preventDefault(); + const [t1b, t2b] = e.touches; + const [t1a, t2a] = [touches.get(t1b.identifier), touches.get(t2b.identifier)]; + + // pinch: distance between the touch points at times a and b + const dta = Math.hypot(t1a.screenX - t2a.screenX, t1a.screenY - t2a.screenY); + const dtb = Math.hypot(t1b.screenX - t2b.screenX, t1b.screenY - t2b.screenY); + + // swipe: distance each touch point moved between times a and b + const dt1 = Math.hypot(t1a.screenX - t1b.screenX, t1a.screenY - t1b.screenY); + const dt2 = Math.hypot(t2a.screenX - t2b.screenX, t2a.screenY - t2b.screenY); + + if (Math.abs(dta - dtb) > threshold) { + for (let t of e.touches) touches.set(t.identifier, t); + const direction = dta - dtb > 0 ? "out" : "in"; + if (active === direction) return; + t1b.target.dispatchEvent(event("pinch", direction)); + active = direction; + } else if (dt1 > threshold && dt2 > threshold) { + for (let t of e.touches) touches.set(t.identifier, t); + const dx = t1a.screenX - t1b.screenX + t2a.screenX - t2b.screenX; + const dy = t1a.screenY - t1b.screenY + t2a.screenY - t2b.screenY; + const direction = dy*dy > dx*dx ? dy > 0 ? "up" : "down" : dx > 0 ? "left" : "right"; + if (active === direction) return; + t1b.target.dispatchEvent(event("swipe", direction)); + active = direction; + console.log("swipe") + } else if (dt1 + dt2 < (t1b.radiusX + t1b.radiusY + t2b.radiusX + t2b.radiusY) * 10) { + active = "tap" + } else { + active = "gesture"; + } + }, {passive: false}); +} diff --git a/docs/style/index.html b/docs/style/index.html new file mode 100644 index 0000000..ecfbcc6 --- /dev/null +++ b/docs/style/index.html @@ -0,0 +1,101 @@ + + + + + + style + + + + +
+
+

Typography

+
+
+

Heading 1

+

Heading 2

+

Heading 3

+

A very long heading that breaks into multiple lines

+

Heading 4

+
+
+

A paragraph with + a link +

+

A paragraph with + bold, + marked, + underlined, + emphasised and + small text. Also inline code +

+
+
+
+ +
+

TODO

+
+
+ buttons + + +
+
+ inputs + + + + + + + +
+ +
+

Simple table with header

+ + + + + + + + + + + + + +
First nameLast name
JohnDoe
JaneDoe
+
    +
  • forms
  • + +
  • generic layouts (e.g. grid all rows same size, gaps, ...)
  • +
  • dark mode + +
      +
    • forms
    • + +
    • generic layouts (e.g. grid all rows same size, gaps, ...)
    • +
    • dark mode
    • +
    +
  • + +
+
+
col a
+
col b
+
col c
+
+
+ + + +
+ + + \ No newline at end of file diff --git a/docs/style/style.css b/docs/style/style.css new file mode 100644 index 0000000..82542b4 --- /dev/null +++ b/docs/style/style.css @@ -0,0 +1,251 @@ +:root { + --bg: #fff; + --mg: #444; + --fg: #000; + --hl: hsla(95, 50%, 70%, 50%); + --ll: #ddd; + + --hf: sans-serif; + --bf: system-ui; + + --bw: .1rem; +} + +*, ::after, ::before { + box-sizing: border-box; +} + +html { + height: 100%; +} + +body { + font: normal 1.2em/1.6 var(--bf); + background: var(--bg); + color: var(--fg); + min-height: 100%; + margin: 0; +} + +section { + display: flex; + flex-direction: column; + gap: 1em; +} + +h1 { font: bold 3em var(--hf); } +h2 { font: bold 1.75em var(--hf); } +h3 { font: bold 1.25em var(--hf); } +h4 { font: bold 0.75em var(--hf); } + +h1, h2, h3 { + color: var(--mg); + display: inline; + background: linear-gradient(180deg, var(--bg) 60%, var(--hl) 60%) no-repeat; + background-position: .25em; + line-height: 1.2; + width: max-content; +} + +:is(h1, h2, h3)::before, :is(h1, h2, h3)::after { + content: ""; + display: block; +} + +p { + margin: 1em 0; + line-height: 1.6; +} + +img { + display: block; + max-width: 100%; +} + +button { + display: inline-block; + font: inherit; + padding: .25em .5em; + margin: .5em 0; + background: var(--bg); + color: var(--fg); + border: var(--bw) solid var(--fg); + box-shadow: var(--bw) var(--bw) 0 var(--fg); + border-radius: 0; + cursor: pointer; +} + +button:is(:active, :focus) { + filter: brightness(90%); +} + +button:is(:active, :focus) { + outline: .25rem solid var(--hl); + box-shadow: none; + transform: translate(var(--bw), 0); +} + +:is(button,input):disabled:is(:disabled, :hover, :focus) { + color: var(--mg); + border-color: var(--mg); + outline: none; + box-shadow: none; + transform: none; + filter: none; + cursor: not-allowed; +} + +input, textarea, select { + font: inherit; + box-shadow: none; + font-size: 1em; + color: var(--fg); + background-color: var(--bg); + border: var(--bw) solid var(--fg); + border-radius: 0; + padding: .25em .5em; + margin: .5em 0; +} + +input:not([type=checkbox], [type=radio]) { + width: 100%; + appearance: none; +} + +input:is([type=checkbox], [type=radio]) { + transform: scale(1.5); + margin: 1em; +} + +input:not(:placeholder-shown):invalid { + border-color: red; +} + +:is(input, textarea, select):is(:active, :focus) { + outline: .25rem solid var(--hl); +} + +a { + color: var(--fg); + background: var(--hl); + padding: 0 .25em; + text-decoration: none; + box-shadow: 0 var(--bw) 0 var(--fg); +} + +a:visited { + background: var(--ll); +} + +table { + text-align: left; + font-size: 1rem; + border-collapse: collapse; + width: 100%; + border-spacing: 0; + margin: 2em 0; +} + +table td, th { + padding: .5em; + border: var(--bw) solid var(--fg); +} + +code { + border: var(--bw) solid var(--fg); + padding: .1em .2em; +} + +mark { + padding: .1em .2em; +} + +pre { + white-space: pre-wrap; + margin: 2em 0 2em 3em; +} + +blockquote { + font-style: italic; + margin: 2em 0 2em 2em; + color: var(--mg); +} + +.row { + display: flex; +} + +.space { + gap: .5em; + margin: 1em; +} + +.bg-hl { + background: var(--hl); +} + +.fg-hl { + color: var(--hl); +} + +form { + width: 100%; +} + +fieldset { + border: var(--bw) solid var(--fg); + padding: 1em; +} + +label { + font-weight: bold; + padding: 3em; +} + +ul { + padding: 0 2ch; +} + +li { + margin: 0.5em 0; +} + +.debug * { + outline: 1px solid #f00 !important; + opacity: 1 !important; + visibility: visible !important; +} + +.logo { + display: inline-block; + white-space: pre-line; + border: .2em solid black; + font: bold 1em monospace; + padding: .125em .25em; + margin: .5em; + pointer-events: visible; +} + +.space > * + * { + margin-top: var(--space, 1em); +} + +@media (pointer: fine) { + a:hover, button:hover { + filter: brightness(90%); + } +} + +@media (min-width: 70ch) { + main { + width: 70ch; + margin: 0 auto; + } + + .col { flex: 1; } + .col2 { flex: 0 0 calc(100% / 2); } + .col3 { flex: 0 0 calc(100% / 3); } + .col4 { flex: 0 0 calc(100% / 4); } + .col5 { flex: 0 0 calc(100% / 5); } + .col10 { flex: 0 0 calc(100% / 10); } +} diff --git a/docs/todomvc/index.html b/docs/todomvc/index.html new file mode 100644 index 0000000..44bc636 --- /dev/null +++ b/docs/todomvc/index.html @@ -0,0 +1,473 @@ + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/docs/todomvc/todomvc.css b/docs/todomvc/todomvc.css new file mode 100644 index 0000000..92047c9 --- /dev/null +++ b/docs/todomvc/todomvc.css @@ -0,0 +1,185 @@ +html { + background-color: #f6f6f6; + font-family: var(--font-sans); +} + +.todoapp { + margin: 0 auto; + width: 40ch; + font-size: 1.5em; +} + +.todoapp h1 { + text-align: center; + font-size: 3em; + margin: 0.5em 0; + color: #bbb; +} + +.new-todo { + background-color: #fff; + border: none; + padding: 1em 2.5em; + width: 100%; + box-shadow: 0 0.5em 1em #ddd; +} + +.new-todo::placeholder { + color: #ddd; + font-style: italic; +} + +.main { + position: relative; + border-top: 1px solid #ddd; + box-shadow: 0 0.5em 1em #ddd; +} + +.toggle-all { + display: none; +} + +label[for=toggle-all]::before { + position: absolute; + top: -2em; + left: 0.5em; + content: "❯"; + display: block; + transform: rotate(90deg); + width: 1em; + color: #bbb; +} + +.todo-list { + background-color: #fff; +} + +.todo-item { + position: relative; + padding: 0.5em 0; + border-bottom: 1px solid #ddd; + display: flex; + align-items: center; +} + +.todo-item .toggle { + height: 1.5em; + width: 1.5em; + opacity: 0; +} + +.todo-item .toggle + label::before { + content: ""; + position: absolute; + pointer-events: none; + left: 0.25em; + text-align: center; + border-radius:50%; + width: 1.5em; + height: 1.5em; + border: 1px solid #ddd; +} + +.todo-item .toggle:checked + label::before { + content: "✓"; + color: var(--green); +} + +.todo-item label { + margin-left: 1em; + width: 100%; +} + +.todo-item.editing { + border: 1px solid grey; + margin-left: 2em; +} +.todo-item .edit { + width: 100%; + padding-left: 0.5em; + border: none; +} + +.todo-item.editing .toggle { + display: none; +} + + +.todo-item .toggle:checked + label { + text-decoration: line-through; + color: #ddd; +} + +.todo-item .destroy { + position: absolute; + right: 0.25em; + background: none; + border: none; +} + +.todo-item:hover .destroy::before { + content: "×"; + color: var(--red); +} + +.footer { + display: grid; + color: #999; + grid-template-columns: 1fr 1fr 1fr; + font-size: 1rem; + padding: 0.5em 1em; + background: #fff; + box-shadow: 0 0.1em 0 #ddd, + 0 0.6em 0em -0.2em #fff, + 0 0.7em 0 -0.2em #ddd, + 0 1.2em 0 -0.4em #fff, + 0 1.3em 0 -0.4em #ddd, + 0 1.3em 1em -0.4em #ddd; +} + +.filters { + display: flex; + justify-content: space-between; + width: 100%; +} + +.filters a { + text-decoration: none; + color: inherit; +} + +.filters a { + border: 1px solid transparent; + border-radius: 0.3em; + padding: 0.15em; +} + +.filters a.selected { + border: 1px solid #bbb; +} + +.filters a:hover { + border: 1px solid #ddd; +} + +.clear-completed { + background: none; + border: none; + color: inherit; + text-align: right; +} + +.clear-completed:hover { + text-decoration: underline; +} + +.info { + margin-top: 5em; + text-align: center; + color: #bbb; + font-size: 0.8em; +} + +:focus { + outline: none; +}