implemented progressive form wrapper (for offline WebKit support)

main
Inga 🏳‍🌈 3 weeks ago
parent ffbd80d30e
commit 5d9e136dcb
  1. 77
      README.md
  2. 138
      src/backend/components/boardgame.tsx
  3. 5
      src/backend/main/index.tsx
  4. 6
      src/frontend/components/board-game.ts
  5. 22
      src/frontend/components/index.ts
  6. 65
      src/frontend/components/progressive-form.ts
  7. 6
      src/frontend/components/reactive-button.ts
  8. 6
      src/frontend/components/reactive-span.ts
  9. 1
      src/jsx-augmentations.ts

@ -287,32 +287,49 @@ None.
## Supported browser engines
There are three main fully functional modes:
1. Main simple offline-based JS-only mode, using modern web standards;
2. More elaborate (almost) offline-based (almost) JS-only mode (game works offline,
but demo counters on the top of the page (separate from the game) only work online
because I don't have the motivation to redesign `ReactiveButton` and `ReactiveSpan`),
using modern web standards _without_ custom built-in elements
(which means old, but not too old Blink-based browsers like Chromium 54-66,
and all modern WebKit-based browsers like Safari 10.1+);
3. Online-based (plain old client-server) mode available in most browsers even with JS disabled.
The main required features are:
* Custom elements: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define
(this demo will fall back to client-server mode if `window.customElements.define` is not available).
* Custom build-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is
(again, this demo will fall back to client-server mode if this is not supported).
* ES modules: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type.
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project).
* And `:not` CSS selector for critical UI features,
* ES modules: https://developer.mozilla.org/en-US/docs/Web/HTML/Element/script/type,
without them no JS will load, and this demo will fall back to `mode 3`
(can be worked around by using bundlers, but this will only affect Chromium-based browsers from 54 (2016) to 60 (2017),
so it's not worth it to change the entire build setup just for better UX in these browsers).
* Custom elements: https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/define,
without them this demo will fall back to `mode 3`.
* Custom built-in elements: https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is,
without them this demo will fall back to `mode 2`.
* `:scope` CSS selector, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/CSS/:scope
* `MutationObserver`, needed in `mode 2` only:
https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver
* `:not` CSS selector for critical UI features, needed in all modes
(hiding and showing different messages and buttons depending on the game state):
https://developer.mozilla.org/en-US/docs/Web/CSS/:not.
* Also CSS grid for nice presentation: https://developer.mozilla.org/en-US/docs/Web/CSS/grid-template
(presentation is less nice on small screens, should be easy to make layout responsive, but I already spent too much time on this project).
Therefore, for desktop browsers:
* Gecko (Firefox): checked and works in v132, both with JS enabled (offline mode) and with JS disabled (plain old client-server mode).
Should work in offline mode starting with Firefox 63 (2018); should fall back plain old client-server mode in all versions,
* Gecko (Firefox): checked and works in v132, both with JS enabled (`mode 1`) and with JS disabled (`mode 3`).
Should work in `mode 1` starting with Firefox 63 (2018); should fall back plain old client-server mode in all versions,
although the design in Firefox 51 (2017) and older will probably not look very good due to the lack of the recent CSS features.
* Blink (e.g. Chromium and Chromium-based browsers): superficially checked in Chromium 130, basic functionality seems to work.
Should work in offline mode starting with Chrome 67 (2018); should fall back to plain old client-server mode in all versions,
* Blink (e.g. Chromium and Chromium-based browsers): superficially checked in Chromium 130, basic functionality seems to work in `mode 1`.
Should work in `mode 1` starting with Chrome 67 (2018); should fall back to `mode 2` in Chrome plain old client-server mode in all versions,
although the design in Chrome 56 (2017) and older will probably not look very good due to the lack of the recent CSS features.
* WebKit (e.g. qutebrowser, Konqueror, Epiphany and notably Safari): not checked; should **not** work in offline mode,
because WebKit does not fully implement decade-old standard which Gecko and Blink supported since 2018:
https://github.com/WebKit/standards-positions/issues/97.
Everything should still work in online mode, falling back on client-server communication,
except that the design in Safari before 10.1 (2017) will not look very good (but will still be functional),
* WebKit (e.g. qutebrowser, Konqueror, Epiphany and notably Safari): superficialy checked in [BadWolf](https://hacktivis.me/projects/badwolf) 1.3 / webkit2gtk-4.1 2.46.3,
basic functionality seems to work both with JS enabled (in `mode 2`) and with JS disabled (in `mode 3`).
Should work with JS enabled in `mode 2` in all browsers supporting custom elements (e.g. in Safari 10.1 (2017) and newer);
should fall back to `mode 3` in older browsers, except that the design in Safari before 10.1 (2017) will not look very good (but will still be functional),
and will probably become disfunctional in Safari before 3.1 (2008).
* Servo: checked, does **not** work in nightly as of 2024-11-20 and as of 2024-11-25 (even progressive form doesn't work),
and its developer tools host seemingly crashes on this page
@ -323,9 +340,9 @@ Therefore, for desktop browsers:
3. Gating offline JS mode for non-servo browsers only will mean that in the future, when servo is fixed,
this demo will only work in client-server mode, just because servo once had a bug. Not ideal.
(Also not even attempting to use dynamic features in servo will make it more difficult to debug the problem in it.)
* EdgeHTML (classic MS Edge before they migrated to Chromium): not checked, should fall back to client-server mode in all versions,
* EdgeHTML (classic MS Edge before they migrated to Chromium): not checked, should fall back to `mode 3` in all versions,
but the page will not look very pretty in Edge before 16 (2017).
* Trident (Internet Explorer): not checked, should fall back to client-server mode,
* Trident (Internet Explorer): not checked, should fall back to `mode 3`,
UI should be functional (not pretty) in IE8+ (2008) and dysfunctional in IE7 and lower
(fixable by refactoring the approach to displaying/hiding elements to avoid the usage of `:not` selector,
but this is outside the scope of the task).
@ -350,22 +367,15 @@ Therefore, for desktop browsers:
So presumably, according to caniuse.com,
* Offline JS mode should work in 88% desktop browsers and 80% total browsers
* `mode 1` should work in 88% desktop browsers and 80% total browsers
(with Safari being the notable exception contributing 9% on desktop and 18% overall);
* The demo should at least fall back to functional client-server model with the same UI
in at least 98% desktop and at least 96% overall (and likely in at least 98% overall)
(with IE lacking support for grids contributing ~1% on desktop and ~0.5% overall,
and UC Browser and QQ Browser with unknown status of support for grids contributing further 2% overall);
* This demo should at least fall back to functional client-server model with at least functional (but not neccessary pretty) UI
in 99.9% desktop and at least 97% overall browsers (and likely at least 99% overall)
(with older versions of IE lacking support for `:not` selectors contributing 0.1% on desktop,
and UC Browser and QQ Browser with unknown status contributing 2% overall;
it is highly likely that they do actually support `:not` selectors).
* Fallback to `mode 2` should bring the total to at least 96% overall (98% on desktop; for some less popular mobile browsers support status is unknown);
* The demo should at least fall back to functional `mode 3` (client-server) with the functional UI
in 99.9% desktop and presumably 99.9% overall browsers,
with Internet Explorer being the main offender.
## Known issues and quirks
* The game does not work offline fully on client (i.e. without falling back on client-server communication)
in WebKit-based browsers (such as qutebrowser or Safari), see above.
* Playing against the computer opponent is only supported for boards under 12 squares (i.e. 4x3 or 3x4 max).
* The computer player moves might be counterintuitive at times,
and it might be difficult for a human player to get to lose the game,
@ -380,11 +390,6 @@ So presumably, according to caniuse.com,
* Use HTML `<template>`s for rendering state messages,
instead of having them all in HTML and then hiding irrelevant with CSS,
for better graceful degradation in browsers that don't support complex CSS queries with `:not`.
* Implement custom progressive form wrapper element (use `MutationObserver` to find when forms are added/removed),
use it instead of custom built-in element in browsers not supporting custom built-in elements
(i.e. all modern WebKit browsers including Safari 10.1+, and Chrome 54 to 66),
so that these browsers will get offline client-only support too,
instead of falling back to client-server mode.
* Implement error handling and handling of incorrect / malformed game states (since they come from the client).
* Improve UI for large boards (disable autoplayers when board is too large,
hide "enable autoplayer" buttons, provide clear indication to the user instead).

@ -53,74 +53,76 @@ export const getBoardgameHtml = (key: string, gameState: BoardgameStateType, gam
return (
<board-game track={key} className={gameActiveClassNames.join(" ")} game-variant={gameVariant}>
<form method="post" is="progressive-form">
<p class="when-outcome-undecided">
Current player: <span class="current-player-name">{gameState.currentPlayerName}</span>
</p>
<table class="game-board-table">
<tbody class="game-board">
{sequence(gameState.rows).map((row) => (
<tr>
{sequence(gameState.columns).map((column) => (
<td>{getCellHtml({ key, gameState, currentOutcome, row, column })}</td>
))}
</tr>
))}
</tbody>
</table>
<p>
<span class="when-outcome-winx">Player X won</span>
<span class="when-outcome-wino">Player O won</span>
<span class="when-outcome-draw">Draw</span>
</p>
<p class="when-autoplayer-x-disabled">
Currently X moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-enable", { key, buttonValues })}>
Make computer play for X.
</button>
</p>
<p class="when-autoplayer-x-enabled">
Currently X moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p class="when-autoplayer-o-disabled">
Currently O moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-enable", { key, buttonValues })}>
Make computer play for O.
</button>
</p>
<p class="when-autoplayer-o-enabled">
Currently O moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p>
<button {...getGenericButtonAttributes("game-start", { key, buttonValues })}>
<span class="when-game-not-in-progress">Start game</span>
<span class="when-game-in-progress">Restart game</span>
</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-row", { key, buttonValues })}>Add row</button>
{" / "}
<button {...getGenericButtonAttributes("remove-row", { key, buttonValues })}>Remove row</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-column", { key, buttonValues })}>Add column</button>
{" / "}
<button {...getGenericButtonAttributes("remove-column", { key, buttonValues })}>Remove column</button>
</p>
</form>
<progressive-form-wrapper>
<form method="post" is="progressive-form">
<p class="when-outcome-undecided">
Current player: <span class="current-player-name">{gameState.currentPlayerName}</span>
</p>
<table class="game-board-table">
<tbody class="game-board">
{sequence(gameState.rows).map((row) => (
<tr>
{sequence(gameState.columns).map((column) => (
<td>{getCellHtml({ key, gameState, currentOutcome, row, column })}</td>
))}
</tr>
))}
</tbody>
</table>
<p>
<span class="when-outcome-winx">Player X won</span>
<span class="when-outcome-wino">Player O won</span>
<span class="when-outcome-draw">Draw</span>
</p>
<p class="when-autoplayer-x-disabled">
Currently X moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-enable", { key, buttonValues })}>
Make computer play for X.
</button>
</p>
<p class="when-autoplayer-x-enabled">
Currently X moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-x-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p class="when-autoplayer-o-disabled">
Currently O moves are made manually.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-enable", { key, buttonValues })}>
Make computer play for O.
</button>
</p>
<p class="when-autoplayer-o-enabled">
Currently O moves are made by computer.{" "}
<button {...getGenericButtonAttributes("autoplayer-o-disable", { key, buttonValues })}>
Make them manually.
</button>
</p>
<p>
<button {...getGenericButtonAttributes("game-start", { key, buttonValues })}>
<span class="when-game-not-in-progress">Start game</span>
<span class="when-game-in-progress">Restart game</span>
</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-row", { key, buttonValues })}>Add row</button>
{" / "}
<button {...getGenericButtonAttributes("remove-row", { key, buttonValues })}>Remove row</button>
</p>
<p>
<button {...getGenericButtonAttributes("add-column", { key, buttonValues })}>Add column</button>
{" / "}
<button {...getGenericButtonAttributes("remove-column", { key, buttonValues })}>Remove column</button>
</p>
</form>
</progressive-form-wrapper>
</board-game>
);
};

@ -4,6 +4,8 @@ import { handleBoardgame } from "./boardgame-handler.ts";
import { getCounterHtml } from "../components/counter.tsx";
export const mainPageHandler: RequestHandler = (req, res) => {
console.log(`Handling main page: ${req.url}`);
const boardThree = handleBoardgame(req, res, "tictactoe-three-1", "tictactoe-three");
if (!boardThree) {
// No return value from handleBoardgame means that it redirected user to another URL,
@ -36,6 +38,9 @@ export const mainPageHandler: RequestHandler = (req, res) => {
<br />
It works both with JS and without JS; it does not require network connection when JS is used; and it
supports history navigation (you can go back & forward and see how the state changes).
<br />
Note that, for simplicity reasons, this falls back to client-server mode in WebKit-based browsers (e.g.
Safari).
</p>
<ul>
<li>{getCounterHtml(req, "a")}</li>

@ -8,7 +8,7 @@ import { replaceLocation } from "../utils/navigation-utils.ts";
import { TrackingTools } from "../utils/query-tracking-utils.ts";
import { updateWithQueryParams } from "../utils/url-utils.ts";
export class BoardGameComponent extends HTMLElement {
class BoardGameComponent extends HTMLElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null, key: string) {
@ -118,3 +118,7 @@ export class BoardGameComponent extends HTMLElement {
this.trackingTools.disconnectedCallback();
}
}
export const registerBoardGame = () => {
window.customElements.define("board-game", BoardGameComponent);
};

@ -1,19 +1,13 @@
import { BoardGameComponent } from "./board-game.ts";
import { ProgressiveForm } from "./progressive-form.ts";
import { ReactiveButton } from "./reactive-button.ts";
import { ReactiveSpan } from "./reactive-span.ts";
import { registerBoardGame } from "./board-game.ts";
import { registerProgressiveForm } from "./progressive-form.ts";
import { registerReactiveButton } from "./reactive-button.ts";
import { registerReactiveSpan } from "./reactive-span.ts";
export const initializeWebComponents = () => {
if ((window as Partial<typeof window>).customElements?.define) {
// We need to define customized built-in elements first,
// because WebKit (Safari) is not standard-compliant[1] and doesn't support them,
// so hopefully these calls will throw, leaving us with all custom elements or none,
// instead of defining autonomous custom elements and skipping customized built-in elements.
// [1]: https://github.com/WebKit/standards-positions/issues/97
customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
customElements.define("reactive-button", ReactiveButton, { extends: "button" });
customElements.define("reactive-span", ReactiveSpan, { extends: "span" });
customElements.define("board-game", BoardGameComponent);
registerBoardGame();
registerProgressiveForm();
registerReactiveButton();
registerReactiveSpan();
}
};

@ -16,7 +16,7 @@ const submitListener = function (this: HTMLFormElement, e: SubmitEvent) {
e.preventDefault();
};
export class ProgressiveForm extends HTMLFormElement {
class ProgressiveForm extends HTMLFormElement {
connectedCallback() {
if (this.method.toUpperCase() !== "POST") {
throw new Error(
@ -26,4 +26,67 @@ export class ProgressiveForm extends HTMLFormElement {
this.addEventListener("submit", submitListener);
}
disconnectedCallback() {
this.removeEventListener("submit", submitListener);
}
}
// Only needed in browsers that support custom elements but not custom built-in elements
class ProgressiveFormWrapper extends HTMLElement {
private readonly watchedForms: Set<HTMLFormElement> = new Set<HTMLFormElement>();
private observer: MutationObserver | null = null;
private handleChanges() {
const currentForms = new Set(Array.from(this.querySelectorAll(':scope > form[is="progressive-form"]')));
// we need to dereference the set here because we're going to modify it
for (const watchedForm of [...this.watchedForms]) {
if (!currentForms.has(watchedForm)) {
watchedForm.removeEventListener("submit", submitListener);
this.watchedForms.delete(watchedForm);
}
}
for (const currentForm of currentForms) {
if (!this.watchedForms.has(currentForm)) {
this.watchedForms.add(currentForm);
currentForm.addEventListener("submit", submitListener);
}
}
}
connectedCallback() {
this.handleChanges();
const observer = new MutationObserver(this.handleChanges.bind(this));
observer.observe(this, {
childList: true,
});
this.observer = observer;
}
disconnectedCallback() {
this.observer?.disconnect();
for (const watchedForm of this.watchedForms) {
watchedForm.removeEventListener("submit", submitListener);
}
this.watchedForms.clear();
}
}
export const registerProgressiveForm = () => {
window.customElements.define("progressive-form", ProgressiveForm, { extends: "form" });
const form = document.createElement("form", { is: "progressive-form" });
if (!(form instanceof ProgressiveForm)) {
// We're probably on WebKit based browsers (e.g. Safari 10.1+), or somewhat old Blink-based (e.g. Chromium 54-66),
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/is for more details.
// Fall back to using ProgressiveFormWrapper.
console.log("Progressive form is unsupported");
window.customElements.define("progressive-form-wrapper", ProgressiveFormWrapper);
}
};

@ -1,6 +1,6 @@
import { TrackingTools } from "../utils/query-tracking-utils.ts";
export class ReactiveButton extends HTMLButtonElement {
class ReactiveButton extends HTMLButtonElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) {
@ -20,3 +20,7 @@ export class ReactiveButton extends HTMLButtonElement {
this.trackingTools.disconnectedCallback();
}
}
export const registerReactiveButton = () => {
window.customElements.define("reactive-button", ReactiveButton, { extends: "button" });
};

@ -1,6 +1,6 @@
import { TrackingTools } from "../utils/query-tracking-utils.ts";
export class ReactiveSpan extends HTMLSpanElement {
class ReactiveSpan extends HTMLSpanElement {
private readonly trackingTools = new TrackingTools(this);
handleTrackedValueUpdate(newValue: string | null) {
@ -19,3 +19,7 @@ export class ReactiveSpan extends HTMLSpanElement {
this.trackingTools.disconnectedCallback();
}
}
export const registerReactiveSpan = () => {
window.customElements.define("reactive-span", ReactiveSpan, { extends: "span" });
};

@ -11,6 +11,7 @@ declare module "preact/jsx-runtime" {
// eslint-disable-next-line @typescript-eslint/consistent-type-definitions -- this is how declaration merging is done
interface IntrinsicElements {
"board-game": HTMLAttributes;
"progressive-form-wrapper": HTMLAttributes;
}
}
}

Loading…
Cancel
Save