website: snapshot

This commit is contained in:
nym21
2026-01-22 15:12:56 +01:00
parent bf13249003
commit b557477770
14 changed files with 221 additions and 2389 deletions

View File

@@ -212,149 +212,6 @@ export function importStyle(href) {
return link;
}
/**
* @template T
* @param {Object} args
* @param {T} args.defaultValue - Fallback when selected value is no longer in choices
* @param {string} [args.id]
* @param {readonly T[] | Accessor<readonly T[]>} args.choices
* @param {boolean} [args.sorted]
* @param {Signals} args.signals
* @param {Signal<T>} args.selected
* @param {(choice: T) => string} [args.toKey] - Extract string key (defaults to identity for strings)
* @param {(choice: T) => string} [args.toLabel] - Extract display label (defaults to identity for strings)
* @param {"radio" | "select"} [args.type] - Render as radio buttons or select dropdown
*/
export function createReactiveChoiceField({
id,
choices: unsortedChoices,
defaultValue,
signals,
selected,
sorted,
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
type = /** @type {const} */ ("radio"),
}) {
const defaultKey = toKey(defaultValue);
const choices = signals.createMemo(() => {
/** @type {readonly T[]} */
let c;
if (typeof unsortedChoices === "function") {
c = unsortedChoices();
} else {
c = unsortedChoices;
}
return sorted
? /** @type {readonly T[]} */ (
/** @type {any} */ (
c.toSorted((a, b) => toLabel(a).localeCompare(toLabel(b)))
)
)
: c;
});
/** @param {string} key */
const fromKey = (key) =>
choices().find((c) => toKey(c) === key) ?? defaultValue;
const field = window.document.createElement("div");
field.classList.add("field");
const div = window.document.createElement("div");
field.append(div);
/** @type {HTMLElement | null} */
let remainingSmall = null;
if (type === "select") {
remainingSmall = window.document.createElement("small");
field.append(remainingSmall);
}
signals.createScopedEffect(choices, (choices) => {
const s = selected();
const sKey = toKey(s);
const keys = choices.map(toKey);
if (!keys.includes(sKey)) {
if (keys.includes(defaultKey)) {
selected.set(() => defaultValue);
} else if (choices.length) {
selected.set(() => choices[0]);
}
}
div.innerHTML = "";
if (choices.length === 1) {
const span = window.document.createElement("span");
span.textContent = toLabel(choices[0]);
div.append(span);
if (remainingSmall) {
remainingSmall.hidden = true;
}
} else if (type === "select") {
const select = window.document.createElement("select");
select.id = id ?? "";
select.name = id ?? "";
choices.forEach((choice) => {
const option = window.document.createElement("option");
option.value = toKey(choice);
option.textContent = toLabel(choice);
if (toKey(choice) === sKey) {
option.selected = true;
}
select.append(option);
});
select.addEventListener("change", () => {
selected.set(() => fromKey(select.value));
});
div.append(select);
if (remainingSmall) {
const remaining = choices.length - 1;
if (remaining > 0) {
remainingSmall.textContent = ` +${remaining}`;
remainingSmall.hidden = false;
} else {
remainingSmall.hidden = true;
}
}
} else {
const fieldId = id ?? "";
choices.forEach((choice) => {
const choiceKey = toKey(choice);
const choiceLabel = toLabel(choice);
const { label } = createLabeledInput({
inputId: `${fieldId}-${choiceKey.toLowerCase()}`,
inputName: fieldId,
inputValue: choiceKey,
inputChecked: choiceKey === sKey,
// title: choiceLabel,
type: "radio",
});
const text = window.document.createTextNode(choiceLabel);
label.append(text);
div.append(label);
});
field.addEventListener("change", (event) => {
// @ts-ignore
const value = event.target.value;
selected.set(() => fromKey(value));
});
}
});
return field;
}
/**
* @template T
* @param {Object} args

View File

@@ -1,14 +1,14 @@
import signals from "../signals.js";
/**
* @template T
* @param {(callback: (value: T) => void) => WebSocket} creator
*/
function createWebsocket(creator) {
let ws = /** @type {WebSocket | null} */ (null);
let _live = false;
let _latest = /** @type {T | null} */ (null);
const live = signals.createSignal(false);
const latest = signals.createSignal(/** @type {T | null} */ (null));
/** @type {Set<(value: T) => void>} */
const listeners = new Set();
function reinitWebSocket() {
if (!ws || ws.readyState === ws.CLOSED) {
@@ -22,19 +22,31 @@ function createWebsocket(creator) {
}
const resource = {
live,
latest,
live() {
return _live;
},
latest() {
return _latest;
},
/** @param {(value: T) => void} callback */
onLatest(callback) {
listeners.add(callback);
return () => listeners.delete(callback);
},
open() {
ws = creator((value) => latest.set(() => value));
ws = creator((value) => {
_latest = value;
listeners.forEach((cb) => cb(value));
});
ws.addEventListener("open", () => {
console.log("ws: open");
live.set(true);
_live = true;
});
ws.addEventListener("close", () => {
console.log("ws: close");
live.set(false);
_live = false;
});
window.document.addEventListener(
@@ -51,7 +63,7 @@ function createWebsocket(creator) {
reinitWebSocketIfDocumentNotHidden,
);
window.document.removeEventListener("online", reinitWebSocket);
live.set(false);
_live = false;
ws = null;
},
};