website: snapshot

This commit is contained in:
nym21
2026-01-21 18:39:32 +01:00
parent 1456f47fd1
commit e29387f3c1
16 changed files with 465 additions and 344 deletions

View File

@@ -225,7 +225,7 @@ export function importStyle(href) {
* @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 createChoiceField({
export function createReactiveChoiceField({
id,
choices: unsortedChoices,
defaultValue,
@@ -257,7 +257,8 @@ export function createChoiceField({
});
/** @param {string} key */
const fromKey = (key) => choices().find((c) => toKey(c) === key) ?? defaultValue;
const fromKey = (key) =>
choices().find((c) => toKey(c) === key) ?? defaultValue;
const field = window.document.createElement("div");
field.classList.add("field");
@@ -354,6 +355,85 @@ export function createChoiceField({
return field;
}
/**
* @template T
* @param {Object} args
* @param {T} args.initialValue
* @param {string} [args.id]
* @param {readonly T[]} args.choices
* @param {(value: T) => void} [args.onChange]
* @param {(choice: T) => string} [args.toKey]
* @param {(choice: T) => string} [args.toLabel]
* @param {"radio" | "select"} [args.type]
*/
export function createChoiceField({
id,
choices,
initialValue,
onChange,
toKey = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
toLabel = /** @type {(choice: T) => string} */ ((/** @type {any} */ c) => c),
type = "radio",
}) {
const field = window.document.createElement("div");
field.classList.add("field");
const div = window.document.createElement("div");
field.append(div);
const initialKey = toKey(initialValue);
/** @param {string} key */
const fromKey = (key) =>
choices.find((c) => toKey(c) === key) ?? initialValue;
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) === initialKey) {
option.selected = true;
}
select.append(option);
});
select.addEventListener("change", () => {
onChange?.(fromKey(select.value));
});
div.append(select);
} 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 === initialKey,
type: "radio",
});
const text = window.document.createTextNode(choiceLabel);
label.append(text);
div.append(label);
});
field.addEventListener("change", (event) => {
// @ts-ignore
onChange?.(fromKey(event.target.value));
});
}
return field;
}
/**
* @param {string} [title]
* @param {1 | 2 | 3} [level]

View File

@@ -0,0 +1,72 @@
import { readParam, writeParam } from "./url.js";
import { readStored, writeToStorage } from "./storage.js";
import { debounce } from "./timing.js";
/**
* @template T
* @param {Object} args
* @param {T} args.defaultValue
* @param {string} [args.storageKey]
* @param {string} [args.urlKey]
* @param {(v: T) => string} args.serialize
* @param {(s: string) => T} args.deserialize
* @param {boolean} [args.saveDefaultValue]
*/
export function createPersistedValue({
defaultValue,
storageKey,
urlKey,
serialize,
deserialize,
saveDefaultValue = false,
}) {
const defaultSerialized = serialize(defaultValue);
// Read: URL > localStorage > default
let serialized = urlKey ? readParam(urlKey) : null;
if (serialized === null && storageKey) {
serialized = readStored(storageKey);
}
let value = serialized !== null ? deserialize(serialized) : defaultValue;
/** @param {T} v */
const write = (v) => {
const s = serialize(v);
const isDefault = s === defaultSerialized;
if (storageKey) {
if (!isDefault || saveDefaultValue) {
writeToStorage(storageKey, s);
} else {
writeToStorage(storageKey, null);
}
}
if (urlKey) {
writeParam(urlKey, !isDefault || saveDefaultValue ? s : null);
}
};
const debouncedWrite = debounce(write, 250);
// Write initial value
write(value);
return {
get value() {
return value;
},
/** @param {T} v */
set(v) {
value = v;
debouncedWrite(v);
},
/** @param {T} v */
setImmediate(v) {
value = v;
write(v);
},
};
}
/** @typedef {ReturnType<typeof createPersistedValue>} PersistedValue */

View File

@@ -110,6 +110,10 @@ export const serdeBool = {
},
};
/**
* @typedef {"timestamp" | "date" | "week" | "month" | "quarter" | "semester" | "year" | "decade"} ChartableIndexName
*/
export const serdeChartableIndex = {
/**
* @param {IndexName} v

View File

@@ -1,4 +1,3 @@
import signals from "../signals.js";
import { readStored, removeStored, writeToStorage } from "./storage.js";
const preferredColorSchemeMatchMedia = window.matchMedia(
@@ -7,7 +6,24 @@ const preferredColorSchemeMatchMedia = window.matchMedia(
const stored = readStored("theme");
const initial = stored ? stored === "dark" : preferredColorSchemeMatchMedia.matches;
export const dark = signals.createSignal(initial);
export let dark = initial;
/** @type {Set<() => void>} */
const callbacks = new Set();
/** @param {() => void} callback */
export function onChange(callback) {
callbacks.add(callback);
return () => callbacks.delete(callback);
}
/** @param {boolean} value */
export function setDark(value) {
if (dark === value) return;
dark = value;
apply(value);
callbacks.forEach((cb) => cb());
}
/** @param {boolean} isDark */
function apply(isDark) {
@@ -17,15 +33,13 @@ apply(initial);
preferredColorSchemeMatchMedia.addEventListener("change", ({ matches }) => {
if (!readStored("theme")) {
dark.set(matches);
apply(matches);
setDark(matches);
}
});
function invert() {
const newValue = !dark();
dark.set(newValue);
apply(newValue);
const newValue = !dark;
setDark(newValue);
if (newValue === preferredColorSchemeMatchMedia.matches) {
removeStored("theme");
} else {