@nano133/nanoterra 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +50 -0
- package/README.md +206 -10
- package/ai/SPEC.md +51 -2
- package/dist/admin/adapters/d1.d.ts +1 -0
- package/dist/admin/adapters/d1.js +6 -4
- package/dist/admin/adapters/firestore.d.ts +1 -0
- package/dist/admin/adapters/firestore.js +7 -5
- package/dist/admin/conformance.d.ts +1 -1
- package/dist/admin/conformance.js +12 -4
- package/dist/admin/handler.d.ts +1 -1
- package/dist/admin/handler.js +2 -2
- package/dist/admin/index.d.ts +2 -2
- package/dist/admin/index.js +1 -1
- package/dist/admin/memory.js +3 -1
- package/dist/admin/panel/gameCard.js +13 -10
- package/dist/admin/panel/icons.d.ts +17 -0
- package/dist/admin/panel/icons.js +3 -0
- package/dist/admin/panel/overview.js +1 -1
- package/dist/admin/store.d.ts +2 -1
- package/dist/admin/switches.d.ts +13 -3
- package/dist/admin/switches.js +25 -6
- package/dist/character/create.js +6 -2
- package/dist/character/layer.d.ts +14 -0
- package/dist/character/layer.js +10 -0
- package/dist/emotes/clip.d.ts +79 -0
- package/dist/emotes/clip.js +165 -0
- package/dist/emotes/clips.d.ts +3 -0
- package/dist/emotes/clips.js +2 -0
- package/dist/emotes/events.d.ts +22 -0
- package/dist/emotes/events.js +19 -0
- package/dist/emotes/icons.d.ts +1 -0
- package/dist/emotes/icons.js +6 -0
- package/dist/emotes/index.d.ts +8 -0
- package/dist/emotes/index.js +11 -0
- package/dist/emotes/list.d.ts +123 -0
- package/dist/emotes/list.js +23 -0
- package/dist/emotes/options.d.ts +16 -0
- package/dist/emotes/options.js +34 -0
- package/dist/emotes/panel/index.d.ts +3 -0
- package/dist/emotes/panel/index.js +9 -0
- package/dist/emotes/panel/mount.d.ts +56 -0
- package/dist/emotes/panel/mount.js +360 -0
- package/dist/emotes/player.d.ts +37 -0
- package/dist/emotes/player.js +101 -0
- package/dist/emotes/wheel.d.ts +42 -0
- package/dist/emotes/wheel.js +110 -0
- package/dist/friends/panel/mount.d.ts +3 -3
- package/dist/friends/panel/mount.js +46 -12
- package/dist/options/common.d.ts +88 -0
- package/dist/options/common.js +59 -0
- package/dist/options/core.d.ts +130 -0
- package/dist/options/core.js +420 -0
- package/dist/options/index.d.ts +7 -0
- package/dist/options/index.js +24 -0
- package/dist/options/panel/index.d.ts +2 -0
- package/dist/options/panel/index.js +10 -0
- package/dist/options/panel/mount.d.ts +30 -0
- package/dist/options/panel/mount.js +378 -0
- package/dist/options/reflect.d.ts +5 -0
- package/dist/options/reflect.js +27 -0
- package/package.json +12 -4
- package/src/admin/adapters/d1.ts +5 -4
- package/src/admin/adapters/firestore.ts +6 -5
- package/src/admin/conformance.ts +14 -5
- package/src/admin/handler.ts +2 -2
- package/src/admin/index.ts +2 -2
- package/src/admin/memory.ts +2 -1
- package/src/admin/panel/gameCard.ts +13 -9
- package/src/admin/panel/icons.ts +3 -0
- package/src/admin/panel/overview.ts +1 -1
- package/src/admin/store.ts +2 -2
- package/src/admin/switches.ts +29 -12
- package/src/character/create.ts +6 -2
- package/src/character/layer.ts +21 -0
- package/src/emotes/clip.ts +204 -0
- package/src/emotes/clips.ts +7 -0
- package/src/emotes/events.ts +28 -0
- package/src/emotes/icons.ts +7 -0
- package/src/emotes/icons.webp +0 -0
- package/src/emotes/index.ts +16 -0
- package/src/emotes/list.ts +33 -0
- package/src/emotes/options.ts +43 -0
- package/src/emotes/panel/index.ts +11 -0
- package/src/emotes/panel/mount.ts +390 -0
- package/src/emotes/panel/style.css +415 -0
- package/src/emotes/player.ts +131 -0
- package/src/emotes/wheel.ts +131 -0
- package/src/friends/panel/mount.ts +47 -13
- package/src/friends/panel/style.css +26 -0
- package/src/options/common.ts +65 -0
- package/src/options/core.ts +465 -0
- package/src/options/index.ts +41 -0
- package/src/options/panel/index.ts +12 -0
- package/src/options/panel/mount.ts +418 -0
- package/src/options/panel/style.css +528 -0
- package/src/options/reflect.ts +28 -0
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
// The Options key and panel of a game: `mountOptions(element, store, options)`. Plain DOM, no framework, no Three.js. It draws the store's defs (../core.ts)
|
|
2
|
+
// in two tabs, Basic and Advanced, each in its groups, and writes every change to the store at once: there is no Save key. A change from elsewhere (the game
|
|
3
|
+
// sets a value, another view resets one) shows at once too, and a section a module adds later (store.add) is drawn when it comes.
|
|
4
|
+
//
|
|
5
|
+
// toggle a switch: the whole row is the key (role="switch").
|
|
6
|
+
// choice a row of keys (role="radiogroup", the arrow keys move) for at most 4 short choices, else a drop-down; a def's `display` chooses.
|
|
7
|
+
// range a slider with its value and unit beside the label. A slider that a finger or the mouse holds is never drawn again under it.
|
|
8
|
+
// key a key binding: the key's name; pressed, it waits for the next key (Escape or Tab stops waiting). A key another binding has is swapped (the store does it).
|
|
9
|
+
//
|
|
10
|
+
// Rows are drawn once and changed in place; the panel never scrolls back to the top on a change (Echo Island's draft, 2026-11-01).
|
|
11
|
+
// "Reset to defaults" asks in the panel (a question and a key for each answer), never in a browser dialog. Escape answers the question first, then closes.
|
|
12
|
+
// The panel is phone first: a sheet from the bottom on a phone held upright, from the right on a wide or sideways screen; every key is 44 px or more.
|
|
13
|
+
// `key: false` draws no key, for a game that opens the panel from its own pause menu (`open()`).
|
|
14
|
+
|
|
15
|
+
import { h } from "../../admin/panel/dom.js";
|
|
16
|
+
import { icon } from "../../admin/panel/icons.js";
|
|
17
|
+
import { keyLabel, type ChoiceDef, type KeyDef, type OptionDef, type OptionsStore, type OptionValue, type RangeDef, type Tab, type ToggleDef } from "../core.js";
|
|
18
|
+
|
|
19
|
+
export type OptionsMountOptions = {
|
|
20
|
+
/** The panel's heading and the key's word. Default "Options". */
|
|
21
|
+
title?: string;
|
|
22
|
+
/** Draw the Options key (a gear and the word). Default true; false for a game that calls `open()` from its own menu. */
|
|
23
|
+
key?: boolean;
|
|
24
|
+
/** Called when the panel opens and closes (a game may pause under it). */
|
|
25
|
+
onOpen?: () => void;
|
|
26
|
+
onClose?: () => void;
|
|
27
|
+
/** A slider was let go (a game plays a sound at the new level). */
|
|
28
|
+
onRangeDone?: (key: string, value: number) => void;
|
|
29
|
+
/** Asks the player a yes or no question (default: a question in the panel, with a key for each answer; never a browser dialog). */
|
|
30
|
+
confirm?: (question: string) => boolean | Promise<boolean>;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
export type MountedOptions = {
|
|
34
|
+
open(): void;
|
|
35
|
+
close(): void;
|
|
36
|
+
readonly isOpen: boolean;
|
|
37
|
+
/** Removes the key and the panel and stops hearing the store. */
|
|
38
|
+
destroy(): void;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const TAB_WORDS: Record<Tab, string> = { basic: "Basic", advanced: "Advanced" };
|
|
42
|
+
const TABS: readonly Tab[] = ["basic", "advanced"];
|
|
43
|
+
export const RESET_QUESTION = "Put every option back to its default?";
|
|
44
|
+
/** On a key binding while it waits for its key. */
|
|
45
|
+
export const PRESS_A_KEY = "Press a key";
|
|
46
|
+
|
|
47
|
+
/** "80%", "3 s", "50". */
|
|
48
|
+
export const rangeText = (def: Pick<RangeDef, "unit">, v: number): string => `${v}${def.unit ?? ""}`;
|
|
49
|
+
|
|
50
|
+
/** Buttons for at most 4 choices of at most 10 characters, else a list; the def's `display` wins. */
|
|
51
|
+
export const choiceDisplay = (def: ChoiceDef): "buttons" | "list" => def.display ?? (def.choices.length <= 4 && def.choices.every((c) => c.label.length <= 10) ? "buttons" : "list");
|
|
52
|
+
|
|
53
|
+
let mounts = 0;
|
|
54
|
+
|
|
55
|
+
export function mountOptions(root: HTMLElement, store: OptionsStore, options: OptionsMountOptions = {}): MountedOptions {
|
|
56
|
+
const n = ++mounts;
|
|
57
|
+
const id = (part: string) => `n133o-${n}-${part}`;
|
|
58
|
+
const title = options.title ?? "Options";
|
|
59
|
+
let isOpen = false;
|
|
60
|
+
let destroyed = false;
|
|
61
|
+
let tab: Tab = "basic";
|
|
62
|
+
let returnTo: HTMLElement | null = null;
|
|
63
|
+
const cleanup: (() => void)[] = [];
|
|
64
|
+
|
|
65
|
+
const key =
|
|
66
|
+
options.key === false
|
|
67
|
+
? null
|
|
68
|
+
: h("button", { class: "n133o-key", type: "button", "aria-expanded": "false", "aria-controls": id("panel"), "aria-haspopup": "dialog" }, icon("gear", 20), h("span", { class: "n133o-key-word" }, title));
|
|
69
|
+
const closeKey = h("button", { class: "n133o-close", type: "button", "aria-label": `Close ${title.toLowerCase()}` }, icon("close", 20));
|
|
70
|
+
const tabKeys = Object.fromEntries(
|
|
71
|
+
TABS.map((t) => [t, h("button", { class: "n133o-tab", type: "button", role: "tab", id: id(`tab-${t}`), "aria-controls": id(`pane-${t}`), "data-tab": t }, TAB_WORDS[t])]),
|
|
72
|
+
) as Record<Tab, HTMLButtonElement>;
|
|
73
|
+
const tabList = h("div", { class: "n133o-tabs", role: "tablist", "aria-label": title }, tabKeys.basic, tabKeys.advanced);
|
|
74
|
+
const panes = Object.fromEntries(TABS.map((t) => [t, h("div", { class: "n133o-pane", role: "tabpanel", id: id(`pane-${t}`), "aria-labelledby": id(`tab-${t}`), "data-tab": t })])) as Record<Tab, HTMLDivElement>;
|
|
75
|
+
const body = h("div", { class: "n133o-body" }, panes.basic, panes.advanced);
|
|
76
|
+
|
|
77
|
+
const question = h("p", { class: "n133o-ask-q", id: id("ask") });
|
|
78
|
+
const yesKey = h("button", { class: "n133o-btn n133o-primary", type: "button" });
|
|
79
|
+
const noKey = h("button", { class: "n133o-btn", type: "button" }, "Cancel");
|
|
80
|
+
const askBox = h("div", { class: "n133o-ask", role: "alertdialog", "aria-labelledby": id("ask") }, question, h("div", { class: "n133o-ask-keys" }, yesKey, noKey));
|
|
81
|
+
askBox.hidden = true;
|
|
82
|
+
const resetKey = h("button", { class: "n133o-btn n133o-reset", type: "button" }, icon("refresh", 18), "Reset to defaults");
|
|
83
|
+
const foot = h("footer", { class: "n133o-foot" }, askBox, resetKey);
|
|
84
|
+
|
|
85
|
+
const panel = h(
|
|
86
|
+
"section",
|
|
87
|
+
{ class: "n133o-panel", id: id("panel"), role: "dialog", "aria-modal": "true", "aria-labelledby": id("title"), "data-open": "false" },
|
|
88
|
+
h("header", { class: "n133o-head" }, h("h2", { class: "n133o-title", id: id("title") }, title), closeKey),
|
|
89
|
+
tabList,
|
|
90
|
+
body,
|
|
91
|
+
foot,
|
|
92
|
+
);
|
|
93
|
+
const scrim = h("div", { class: "n133o-scrim", "data-open": "false", "aria-hidden": "true" });
|
|
94
|
+
const wrap = h("div", { class: "n133-options" }, key, scrim, panel);
|
|
95
|
+
root.append(wrap);
|
|
96
|
+
|
|
97
|
+
// ---- the rows ----
|
|
98
|
+
|
|
99
|
+
/** Each drawn key's way to show a new value. */
|
|
100
|
+
let shows = new Map<string, (v: OptionValue) => void>();
|
|
101
|
+
|
|
102
|
+
const textOf = (def: OptionDef, labelTag: "span" | "label" = "span", forId?: string) => {
|
|
103
|
+
const label = h(labelTag, { class: "n133o-label", id: id(`l-${def.key}`), for: forId }, def.label);
|
|
104
|
+
const help = def.help ? h("span", { class: "n133o-help", id: id(`h-${def.key}`) }, def.help) : null;
|
|
105
|
+
return { label, help, describedBy: help ? id(`h-${def.key}`) : undefined };
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
function toggleRow(def: ToggleDef): HTMLElement {
|
|
109
|
+
const t = textOf(def);
|
|
110
|
+
const row = h(
|
|
111
|
+
"button",
|
|
112
|
+
{ class: "n133o-row n133o-toggle", type: "button", role: "switch", "aria-labelledby": id(`l-${def.key}`), "aria-describedby": t.describedBy, "data-key": def.key },
|
|
113
|
+
h("span", { class: "n133o-text" }, t.label, t.help),
|
|
114
|
+
h("span", { class: "n133o-switch", "aria-hidden": "true" }, h("span", { class: "n133o-knob" })),
|
|
115
|
+
);
|
|
116
|
+
row.addEventListener("click", () => store.set(def.key, !store.get<boolean>(def.key)));
|
|
117
|
+
shows.set(def.key, (v) => row.setAttribute("aria-checked", String(v === true)));
|
|
118
|
+
return row;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function choiceRow(def: ChoiceDef): HTMLElement {
|
|
122
|
+
if (choiceDisplay(def) === "list") {
|
|
123
|
+
const selectId = id(`c-${def.key}`);
|
|
124
|
+
const t = textOf(def, "label", selectId);
|
|
125
|
+
const byIndex = def.choices.map((c) => c.value);
|
|
126
|
+
const select = h("select", { class: "n133o-select", id: selectId, "aria-describedby": t.describedBy }, ...def.choices.map((c, i) => h("option", { value: String(i) }, c.label)));
|
|
127
|
+
select.addEventListener("change", () => store.set(def.key, byIndex[Number(select.value)]));
|
|
128
|
+
shows.set(def.key, (v) => (select.value = String(Math.max(0, byIndex.indexOf(v as never)))));
|
|
129
|
+
return h("div", { class: "n133o-row n133o-choice", "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), select);
|
|
130
|
+
}
|
|
131
|
+
const t = textOf(def);
|
|
132
|
+
const keys = def.choices.map((c) => h("button", { class: "n133o-seg-key", type: "button", role: "radio" }, c.label));
|
|
133
|
+
const group = h("div", { class: "n133o-seg", role: "radiogroup", "aria-labelledby": id(`l-${def.key}`), "aria-describedby": t.describedBy }, ...keys);
|
|
134
|
+
keys.forEach((k, i) => k.addEventListener("click", () => store.set(def.key, def.choices[i].value)));
|
|
135
|
+
group.addEventListener("keydown", (e) => {
|
|
136
|
+
const at = keys.indexOf(document.activeElement as HTMLButtonElement);
|
|
137
|
+
const to = moveKey(e as KeyboardEvent, at, keys.length);
|
|
138
|
+
if (to === null) return;
|
|
139
|
+
e.preventDefault();
|
|
140
|
+
store.set(def.key, def.choices[to].value);
|
|
141
|
+
keys[to].focus();
|
|
142
|
+
});
|
|
143
|
+
shows.set(def.key, (v) =>
|
|
144
|
+
keys.forEach((k, i) => {
|
|
145
|
+
const on = def.choices[i].value === v;
|
|
146
|
+
k.setAttribute("aria-checked", String(on));
|
|
147
|
+
k.tabIndex = on ? 0 : -1;
|
|
148
|
+
}),
|
|
149
|
+
);
|
|
150
|
+
return h("div", { class: "n133o-row n133o-choice", "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), group);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function rangeRow(def: RangeDef): HTMLElement {
|
|
154
|
+
const inputId = id(`r-${def.key}`);
|
|
155
|
+
const t = textOf(def, "label", inputId);
|
|
156
|
+
const value = h("output", { class: "n133o-value", for: inputId, "aria-hidden": "true" });
|
|
157
|
+
const input = h("input", { class: "n133o-range-input", type: "range", id: inputId, min: def.min, max: def.max, step: def.step, "aria-describedby": t.describedBy });
|
|
158
|
+
input.addEventListener("input", () => store.set(def.key, Number(input.value)));
|
|
159
|
+
input.addEventListener("change", () => options.onRangeDone?.(def.key, Number(input.value)));
|
|
160
|
+
input.addEventListener("pointerdown", () => {
|
|
161
|
+
held = true;
|
|
162
|
+
const letGo = () => {
|
|
163
|
+
held = false;
|
|
164
|
+
window.removeEventListener("pointerup", letGo);
|
|
165
|
+
window.removeEventListener("pointercancel", letGo);
|
|
166
|
+
if (drawLater) draw();
|
|
167
|
+
};
|
|
168
|
+
window.addEventListener("pointerup", letGo);
|
|
169
|
+
window.addEventListener("pointercancel", letGo);
|
|
170
|
+
});
|
|
171
|
+
shows.set(def.key, (v) => {
|
|
172
|
+
const num = v as number;
|
|
173
|
+
if (Number(input.value) !== num) input.value = String(num);
|
|
174
|
+
value.textContent = rangeText(def, num);
|
|
175
|
+
input.setAttribute("aria-valuetext", rangeText(def, num));
|
|
176
|
+
// The filled part of the track, for the stylesheet.
|
|
177
|
+
input.style.setProperty("--n133o-fill", `${((num - def.min) / (def.max - def.min)) * 100}%`);
|
|
178
|
+
});
|
|
179
|
+
return h("div", { class: "n133o-row n133o-range", "data-key": def.key }, h("span", { class: "n133o-text n133o-range-text" }, t.label, value, t.help), input);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** The binding that waits for its key, and the call that stops it. */
|
|
183
|
+
let waiting: { key: string; stop: () => void } | null = null;
|
|
184
|
+
const stopWaiting = () => waiting?.stop();
|
|
185
|
+
|
|
186
|
+
function keyRow(def: KeyDef): HTMLElement {
|
|
187
|
+
const t = textOf(def);
|
|
188
|
+
const cap = h("button", { class: "n133o-keycap", type: "button", "aria-describedby": t.describedBy });
|
|
189
|
+
const show = (v: OptionValue) => {
|
|
190
|
+
if (waiting?.key === def.key) return;
|
|
191
|
+
cap.textContent = keyLabel(String(v));
|
|
192
|
+
cap.setAttribute("aria-label", `${def.label}: ${keyLabel(String(v))}. Press to change`);
|
|
193
|
+
cap.setAttribute("aria-pressed", "false");
|
|
194
|
+
};
|
|
195
|
+
cap.addEventListener("click", () => {
|
|
196
|
+
if (waiting?.key === def.key) return stopWaiting();
|
|
197
|
+
stopWaiting();
|
|
198
|
+
const onKey = (e: KeyboardEvent) => {
|
|
199
|
+
// Tab stops waiting and moves the focus as usual.
|
|
200
|
+
if (e.code === "Tab") return stop();
|
|
201
|
+
// The capture phase of the window: the game and the panel's Escape never hear this key.
|
|
202
|
+
e.preventDefault();
|
|
203
|
+
e.stopImmediatePropagation();
|
|
204
|
+
stop();
|
|
205
|
+
if (e.code && e.code !== "Escape") store.set(def.key, e.code);
|
|
206
|
+
};
|
|
207
|
+
const stop = () => {
|
|
208
|
+
window.removeEventListener("keydown", onKey, true);
|
|
209
|
+
waiting = null;
|
|
210
|
+
show(store.get(def.key));
|
|
211
|
+
};
|
|
212
|
+
waiting = { key: def.key, stop };
|
|
213
|
+
window.addEventListener("keydown", onKey, true);
|
|
214
|
+
cap.textContent = PRESS_A_KEY;
|
|
215
|
+
cap.setAttribute("aria-label", `${def.label}: ${PRESS_A_KEY.toLowerCase()}. Escape keeps the old key`);
|
|
216
|
+
cap.setAttribute("aria-pressed", "true");
|
|
217
|
+
});
|
|
218
|
+
shows.set(def.key, show);
|
|
219
|
+
return h("div", { class: "n133o-row n133o-key-row", "data-key": def.key }, h("span", { class: "n133o-text" }, t.label, t.help), cap);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const rowFor = (def: OptionDef) => (def.kind === "toggle" ? toggleRow(def) : def.kind === "choice" ? choiceRow(def) : def.kind === "key" ? keyRow(def) : rangeRow(def));
|
|
223
|
+
|
|
224
|
+
/** A finger or the mouse holds a slider: a new draw waits until it lets go. */
|
|
225
|
+
let held = false;
|
|
226
|
+
let drawLater = false;
|
|
227
|
+
|
|
228
|
+
/** Draws both tabs from the store's defs (the ones for this device), in their groups. Keeps the tab and, when it can, the focus. */
|
|
229
|
+
function draw() {
|
|
230
|
+
if (held) {
|
|
231
|
+
drawLater = true;
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
drawLater = false;
|
|
235
|
+
stopWaiting();
|
|
236
|
+
const scroll = body.scrollTop;
|
|
237
|
+
const focusKey = (document.activeElement as HTMLElement | null)?.closest?.("[data-key]")?.getAttribute("data-key") ?? null;
|
|
238
|
+
shows = new Map();
|
|
239
|
+
const shown = store.defs().filter((d) => !d.devices || d.devices.includes(store.device));
|
|
240
|
+
const has: Record<Tab, boolean> = { basic: false, advanced: false };
|
|
241
|
+
for (const t of TABS) {
|
|
242
|
+
const defs = shown.filter((d) => d.tab === t);
|
|
243
|
+
has[t] = defs.length > 0;
|
|
244
|
+
const loose = defs.filter((d) => !d.group);
|
|
245
|
+
const groups = [...new Set(defs.filter((d) => d.group).map((d) => d.group!))];
|
|
246
|
+
const parts: HTMLElement[] = [];
|
|
247
|
+
if (loose.length) parts.push(h("div", { class: "n133o-rows" }, ...loose.map(rowFor)));
|
|
248
|
+
for (const g of groups) {
|
|
249
|
+
const gid = id(`g-${t}-${groups.indexOf(g)}`);
|
|
250
|
+
parts.push(h("section", { class: "n133o-group", "aria-labelledby": gid }, h("h3", { class: "n133o-group-title", id: gid }, g), h("div", { class: "n133o-rows" }, ...defs.filter((d) => d.group === g).map(rowFor))));
|
|
251
|
+
}
|
|
252
|
+
panes[t].replaceChildren(...parts);
|
|
253
|
+
}
|
|
254
|
+
// Both tabs when both have rows; one tab alone needs no tab bar.
|
|
255
|
+
const both = has.basic && has.advanced;
|
|
256
|
+
tabList.hidden = !both;
|
|
257
|
+
if (!has[tab]) tab = has.basic ? "basic" : "advanced";
|
|
258
|
+
if (!has.basic && !has.advanced) panes.basic.replaceChildren(h("p", { class: "n133o-empty" }, "No options yet."));
|
|
259
|
+
for (const t of TABS) {
|
|
260
|
+
if (both) panes[t].setAttribute("role", "tabpanel");
|
|
261
|
+
else panes[t].removeAttribute("role");
|
|
262
|
+
}
|
|
263
|
+
selectTab(tab, false);
|
|
264
|
+
body.scrollTop = scroll;
|
|
265
|
+
for (const [k, show] of shows) show(store.get(k));
|
|
266
|
+
resetKey.disabled = store.isDefault();
|
|
267
|
+
// A key is letters, digits, "_", "-" and dots (core.ts checks it), so it goes into the selector as it is.
|
|
268
|
+
const row = focusKey ? panel.querySelector<HTMLElement>(`[data-key="${focusKey}"]`) : null;
|
|
269
|
+
if (row) (row.tagName === "BUTTON" ? row : row.querySelector<HTMLElement>('button[tabindex="0"], select, input'))?.focus();
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function selectTab(t: Tab, focus: boolean) {
|
|
273
|
+
const moved = t !== tab;
|
|
274
|
+
tab = t;
|
|
275
|
+
for (const x of TABS) {
|
|
276
|
+
const on = x === t;
|
|
277
|
+
tabKeys[x].setAttribute("aria-selected", String(on));
|
|
278
|
+
tabKeys[x].tabIndex = on ? 0 : -1;
|
|
279
|
+
panes[x].hidden = !on;
|
|
280
|
+
}
|
|
281
|
+
if (moved) body.scrollTop = 0;
|
|
282
|
+
if (focus) tabKeys[t].focus();
|
|
283
|
+
}
|
|
284
|
+
for (const t of TABS) tabKeys[t].addEventListener("click", () => selectTab(t, true));
|
|
285
|
+
tabList.addEventListener("keydown", (e) => {
|
|
286
|
+
const visible = TABS.filter((t) => !tabKeys[t].hidden);
|
|
287
|
+
const to = moveKey(e as KeyboardEvent, visible.indexOf(tab), visible.length);
|
|
288
|
+
if (to === null) return;
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
selectTab(visible[to], true);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
cleanup.push(
|
|
294
|
+
store.subscribe((k, v) => {
|
|
295
|
+
shows.get(k)?.(v);
|
|
296
|
+
resetKey.disabled = store.isDefault();
|
|
297
|
+
}),
|
|
298
|
+
);
|
|
299
|
+
cleanup.push(store.subscribeDefs(draw));
|
|
300
|
+
draw();
|
|
301
|
+
|
|
302
|
+
// ---- open, close, ask ----
|
|
303
|
+
|
|
304
|
+
const open = () => {
|
|
305
|
+
if (destroyed || isOpen) return;
|
|
306
|
+
isOpen = true;
|
|
307
|
+
returnTo = document.activeElement instanceof HTMLElement && document.activeElement !== document.body ? document.activeElement : null;
|
|
308
|
+
panel.dataset.open = scrim.dataset.open = "true";
|
|
309
|
+
key?.setAttribute("aria-expanded", "true");
|
|
310
|
+
options.onOpen?.();
|
|
311
|
+
(tabList.hidden ? closeKey : tabKeys[tab]).focus();
|
|
312
|
+
};
|
|
313
|
+
const close = () => {
|
|
314
|
+
if (destroyed || !isOpen) return;
|
|
315
|
+
answer?.(false);
|
|
316
|
+
stopWaiting();
|
|
317
|
+
isOpen = false;
|
|
318
|
+
panel.dataset.open = scrim.dataset.open = "false";
|
|
319
|
+
key?.setAttribute("aria-expanded", "false");
|
|
320
|
+
options.onClose?.();
|
|
321
|
+
const back = key ?? (returnTo?.isConnected ? returnTo : null);
|
|
322
|
+
back?.focus();
|
|
323
|
+
};
|
|
324
|
+
key?.addEventListener("click", () => (isOpen ? close() : open()));
|
|
325
|
+
closeKey.addEventListener("click", close);
|
|
326
|
+
scrim.addEventListener("click", close);
|
|
327
|
+
|
|
328
|
+
/** The question in the panel: one at a time (a new one answers the old with no), and closing the panel answers no. */
|
|
329
|
+
let answer: ((yes: boolean) => void) | null = null;
|
|
330
|
+
const askInPanel = (q: string, yesWord: string) =>
|
|
331
|
+
new Promise<boolean>((resolve) => {
|
|
332
|
+
answer?.(false);
|
|
333
|
+
question.textContent = q;
|
|
334
|
+
yesKey.textContent = yesWord;
|
|
335
|
+
askBox.hidden = false;
|
|
336
|
+
resetKey.hidden = true;
|
|
337
|
+
answer = (yes) => {
|
|
338
|
+
answer = null;
|
|
339
|
+
askBox.hidden = true;
|
|
340
|
+
resetKey.hidden = false;
|
|
341
|
+
resolve(yes);
|
|
342
|
+
if (isOpen && !destroyed) resetKey.focus();
|
|
343
|
+
};
|
|
344
|
+
open();
|
|
345
|
+
yesKey.focus();
|
|
346
|
+
});
|
|
347
|
+
yesKey.addEventListener("click", () => answer?.(true));
|
|
348
|
+
noKey.addEventListener("click", () => answer?.(false));
|
|
349
|
+
const ask = (q: string, yesWord: string): Promise<boolean> => (options.confirm ? Promise.resolve(options.confirm(q)) : askInPanel(q, yesWord));
|
|
350
|
+
cleanup.push(() => answer?.(false), stopWaiting);
|
|
351
|
+
|
|
352
|
+
resetKey.addEventListener("click", () => {
|
|
353
|
+
void ask(RESET_QUESTION, "Reset").then((yes) => {
|
|
354
|
+
if (yes && !destroyed) store.reset();
|
|
355
|
+
});
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
const onKey = (e: KeyboardEvent) => {
|
|
359
|
+
if (!isOpen) return;
|
|
360
|
+
if (e.key === "Escape") {
|
|
361
|
+
// Escape answers an open question first, then closes the panel.
|
|
362
|
+
if (answer) answer(false);
|
|
363
|
+
else close();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
if (e.key === "Tab") {
|
|
367
|
+
// The panel holds the focus while it is open (it covers the game).
|
|
368
|
+
const all = Array.from(panel.querySelectorAll<HTMLElement>("button, select, input, [tabindex]")).filter((el) => el.tabIndex >= 0 && !(el as HTMLButtonElement).disabled && el.closest("[hidden]") === null);
|
|
369
|
+
if (!all.length) return;
|
|
370
|
+
const first = all[0];
|
|
371
|
+
const last = all[all.length - 1];
|
|
372
|
+
if (e.shiftKey && (document.activeElement === first || !panel.contains(document.activeElement))) {
|
|
373
|
+
e.preventDefault();
|
|
374
|
+
last.focus();
|
|
375
|
+
} else if (!e.shiftKey && (document.activeElement === last || !panel.contains(document.activeElement))) {
|
|
376
|
+
e.preventDefault();
|
|
377
|
+
first.focus();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
};
|
|
381
|
+
document.addEventListener("keydown", onKey);
|
|
382
|
+
cleanup.push(() => document.removeEventListener("keydown", onKey));
|
|
383
|
+
|
|
384
|
+
return {
|
|
385
|
+
open,
|
|
386
|
+
close,
|
|
387
|
+
get isOpen() {
|
|
388
|
+
return isOpen;
|
|
389
|
+
},
|
|
390
|
+
destroy() {
|
|
391
|
+
if (destroyed) return;
|
|
392
|
+
if (isOpen) close();
|
|
393
|
+
destroyed = true;
|
|
394
|
+
for (const f of cleanup) f();
|
|
395
|
+
wrap.remove();
|
|
396
|
+
},
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
/** The arrow keys, Home and End in a row of `count` (wrapping): the new place, or null for another key. */
|
|
401
|
+
function moveKey(e: KeyboardEvent, at: number, count: number): number | null {
|
|
402
|
+
if (count < 1) return null;
|
|
403
|
+
const from = at < 0 ? 0 : at;
|
|
404
|
+
switch (e.key) {
|
|
405
|
+
case "ArrowRight":
|
|
406
|
+
case "ArrowDown":
|
|
407
|
+
return (from + 1) % count;
|
|
408
|
+
case "ArrowLeft":
|
|
409
|
+
case "ArrowUp":
|
|
410
|
+
return (from - 1 + count) % count;
|
|
411
|
+
case "Home":
|
|
412
|
+
return 0;
|
|
413
|
+
case "End":
|
|
414
|
+
return count - 1;
|
|
415
|
+
default:
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
}
|