@nsnanocat/preference-panes 0.1.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.
@@ -0,0 +1,233 @@
1
+ import { createPreferencesClient } from "./client.mjs";
2
+
3
+ /**
4
+ * 挂载从 BoxJS 实时生成的设置面板和短暂通知。
5
+ * Mount runtime-generated BoxJS controls and transient notifications.
6
+ * @param {import("../types/browser.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
7
+ * @returns {{destroy(): void}} 清理接口 / Cleanup handle.
8
+ */
9
+ export function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
10
+ const document = root.ownerDocument;
11
+ const window = document.defaultView;
12
+ const node = (tag, className, text) => {
13
+ const el = document.createElement(tag);
14
+ el.className = className;
15
+ if (text !== undefined) el.textContent = text;
16
+ return el;
17
+ };
18
+ const shell = node("div", "pp-panel");
19
+ const header = node("header", "pp-header");
20
+ const back = node("button", "pp-back", "返回");
21
+ back.type = "button";
22
+ const heading = node("h1", "pp-title", title);
23
+ const viewport = node("div", "pp-viewport");
24
+ const toast = node("div", "pp-toast");
25
+ toast.setAttribute("role", "status");
26
+ toast.hidden = true;
27
+ header.append(back, heading);
28
+ shell.append(header, viewport, toast);
29
+ root.append(shell);
30
+ let timer,
31
+ routedPath,
32
+ generation = 0,
33
+ active = null,
34
+ saving = false,
35
+ pendingRoute = false,
36
+ destroyed = false;
37
+ const notify = (event) => {
38
+ if (destroyed) return;
39
+ toast.textContent = event.kind === "error" ? `操作失败:${event.message}` : event.operation === "delete" ? "删除成功" : "修改成功";
40
+ toast.dataset.kind = event.kind;
41
+ toast.hidden = false;
42
+ clearTimeout(timer);
43
+ timer = setTimeout(() => {
44
+ toast.hidden = true;
45
+ }, 2400);
46
+ };
47
+ const client = createPreferencesClient({ ...(fetch ? { fetch } : {}), notify });
48
+ function replace(view, direction) {
49
+ const old = viewport.firstElementChild;
50
+ viewport.replaceChildren(view);
51
+ if (old && !document.defaultView.matchMedia("(prefers-reduced-motion: reduce)").matches)
52
+ view.animate(
53
+ [
54
+ { opacity: 0.4, transform: `translateX(${direction * 24}px)` },
55
+ { opacity: 1, transform: "translateX(0)" },
56
+ ],
57
+ { duration: 180, easing: "ease-out" },
58
+ );
59
+ }
60
+ async function open(module) {
61
+ const version = ++generation;
62
+ active = module;
63
+ back.disabled = window.history.length <= 1;
64
+ heading.textContent = module;
65
+ replace(node("p", "pp-loading", "读取设置…"), 1);
66
+ try {
67
+ await client.open(module);
68
+ if (version === generation) controls();
69
+ } catch (error) {
70
+ if (version !== generation) return;
71
+ const view = node("section", "pp-error");
72
+ view.append(node("p", "", `加载失败:${error.message}`));
73
+ const retry = node("button", "", "重新读取");
74
+ retry.onclick = () => open(module);
75
+ view.append(retry);
76
+ replace(view, 1);
77
+ }
78
+ }
79
+ function controls() {
80
+ const { definition, values } = client.snapshot(active);
81
+ const view = node("section", "pp-fields");
82
+ for (const field of definition.fields) {
83
+ const row = node("fieldset", "pp-field");
84
+ row.append(node("legend", "", field.name));
85
+ if (field.description) row.append(node("p", "pp-description", field.description));
86
+ const value = values[field.key];
87
+ let read, write;
88
+ if (field.options && field.type !== "array") {
89
+ const select = node("select", "pp-input");
90
+ select.setAttribute("aria-label", field.name);
91
+ field.options.forEach((option, index) => {
92
+ const item = node("option", "", option.label);
93
+ item.value = String(index);
94
+ select.append(item);
95
+ });
96
+ write = (value) => {
97
+ select.selectedIndex = field.options.findIndex((option) => option.key === value);
98
+ };
99
+ row.append(select);
100
+ read = () => field.options[select.selectedIndex]?.key;
101
+ } else if (field.type === "array" && field.options) {
102
+ const inputs = field.options.map((option) => {
103
+ const label = node("label", "pp-choice", option.label);
104
+ const input = node("input", "");
105
+ input.type = "checkbox";
106
+ input.checked = Array.isArray(value) && value.includes(option.key);
107
+ label.prepend(input);
108
+ row.append(label);
109
+ return { input, key: option.key };
110
+ });
111
+ read = () => inputs.filter((option) => option.input.checked).map((option) => option.key);
112
+ write = (value) => {
113
+ for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
114
+ };
115
+ } else {
116
+ const input = node(field.type === "array" ? "textarea" : "input", "pp-input");
117
+ input.setAttribute("aria-label", field.name);
118
+ if (field.type === "boolean") {
119
+ input.type = "checkbox";
120
+ write = (value) => {
121
+ input.checked = value === true;
122
+ };
123
+ read = () => input.checked;
124
+ } else {
125
+ input.type = field.type === "number" ? "number" : "text";
126
+ write = (value) => {
127
+ input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
128
+ };
129
+ read = () =>
130
+ field.type === "array"
131
+ ? JSON.parse(input.value)
132
+ : field.type === "number"
133
+ ? input.value === ""
134
+ ? Number.NaN
135
+ : Number(input.value)
136
+ : input.value;
137
+ }
138
+ row.append(input);
139
+ }
140
+ write(value);
141
+ const actions = node("div", "pp-actions");
142
+ for (const [operation, label] of [
143
+ ["write", "保存"],
144
+ ["delete", "删除覆盖值"],
145
+ ]) {
146
+ const button = node("button", "", label);
147
+ button.type = "button";
148
+ button.onclick = async () => {
149
+ if (saving) return;
150
+ saving = true;
151
+ back.disabled = true;
152
+ view.querySelectorAll("button,input,select,textarea").forEach((input) => {
153
+ input.disabled = true;
154
+ });
155
+ let success = false;
156
+ try {
157
+ if (operation === "delete") await client.remove(active, field.key);
158
+ else {
159
+ let value;
160
+ try {
161
+ value = read();
162
+ } catch (error) {
163
+ notify({ kind: "error", message: error.message });
164
+ throw error;
165
+ }
166
+ await client.set(active, field.key, value);
167
+ }
168
+ success = true;
169
+ } catch {
170
+ /* 客户端已显示错误通知 / Client already displayed an error notification. */
171
+ } finally {
172
+ saving = false;
173
+ back.disabled = window.history.length <= 1;
174
+ view.querySelectorAll("button,input,select,textarea").forEach((input) => {
175
+ input.disabled = false;
176
+ });
177
+ if (success && !destroyed) {
178
+ // 只更新当前控件,保留其它尚未保存的输入。
179
+ // Update this control without discarding other unsaved inputs.
180
+ write(client.snapshot(active).values[field.key]);
181
+ }
182
+ if (!destroyed && pendingRoute) route();
183
+ }
184
+ };
185
+ actions.append(button);
186
+ }
187
+ row.append(actions);
188
+ view.append(row);
189
+ }
190
+ viewport.replaceChildren(view);
191
+ }
192
+ function route() {
193
+ if (saving) {
194
+ pendingRoute = true;
195
+ return;
196
+ }
197
+ pendingRoute = false;
198
+ if (active) client.leave(active);
199
+ routedPath = window.location.pathname;
200
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
201
+ if (!match) {
202
+ generation++;
203
+ active = null;
204
+ heading.textContent = title;
205
+ replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
206
+ return;
207
+ }
208
+ open(match[1]);
209
+ }
210
+ const onPopState = () => {
211
+ if (window.location.pathname !== routedPath) route();
212
+ };
213
+ const onPageShow = (event) => {
214
+ if (event.persisted) route();
215
+ };
216
+ back.onclick = () => {
217
+ if (!saving) window.history.back();
218
+ };
219
+ window.addEventListener("popstate", onPopState);
220
+ window.addEventListener("pageshow", onPageShow);
221
+ route();
222
+ return {
223
+ destroy() {
224
+ destroyed = true;
225
+ window.removeEventListener("popstate", onPopState);
226
+ window.removeEventListener("pageshow", onPageShow);
227
+ generation++;
228
+ if (active) client.leave(active);
229
+ clearTimeout(timer);
230
+ shell.remove();
231
+ },
232
+ };
233
+ }