@nsnanocat/preference-panes 0.8.0 → 0.9.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.
@@ -9,6 +9,6 @@
9
9
  </head>
10
10
  <body>
11
11
  <main id="preferences"></main>
12
- <script type="module" src="/settings/assets/app.mjs?v=0.8.0"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.9.0"></script>
13
13
  </body>
14
14
  </html>
@@ -1,3 +1,119 @@
1
+ /**
2
+ * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
3
+ * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
4
+ */
5
+ class ActionMenu {
6
+ #button;
7
+ #popup;
8
+ #backdrop;
9
+ #select;
10
+ #document;
11
+ #key = event => {
12
+ if (event.key === "Escape" && !this.#popup.hidden) {
13
+ event.preventDefault();
14
+ this.close();
15
+ this.#button.focus();
16
+ }
17
+ };
18
+
19
+ /**
20
+ * 创建菜单,操作逻辑由调用方提供。
21
+ * Create a menu whose actions are handled by the caller.
22
+ * @param {(id: string) => void} select 菜单选择回调 / Selection callback.
23
+ */
24
+ constructor(select) {
25
+ this.#document = document;
26
+ this.#select = select;
27
+ this.element = document.createElement("span");
28
+ const root = this.element.attachShadow({ mode: "open" });
29
+ root.innerHTML = `<style>
30
+ :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
31
+ :host([hidden]),[hidden]{display:none!important}
32
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
33
+ button:disabled{opacity:.4;cursor:default}
34
+ button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
35
+ #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
36
+ svg{display:block;width:24px;height:24px;fill:currentColor}
37
+ #backdrop{position:fixed;inset:0;z-index:1}
38
+ #items{position:absolute;right:0;top:46px;z-index:2;min-width:160px;padding:6px;background:var(--pp-surface,Canvas);color:var(--pp-text,CanvasText);border:1px solid var(--pp-border,#8884);border-radius:12px;box-shadow:0 8px 28px #0003}
39
+ #items button{display:block;text-align:left;white-space:nowrap;width:100%;padding:11px 14px;border-radius:8px;font:14px/1.4 system-ui,sans-serif}
40
+ #items button:hover{background:#8882}
41
+ #items button[data-danger]{color:#e45656}
42
+ </style><button id="trigger" type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false" aria-controls="items"><svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="4" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="20" cy="12" r="2"/></svg></button><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单" hidden></button><div id="items" role="menu" hidden></div>`;
43
+ this.#button = root.querySelector("#trigger");
44
+ this.#popup = root.querySelector("#items");
45
+ this.#backdrop = root.querySelector("#backdrop");
46
+ this.#button.onclick = () => {
47
+ const open = this.#popup.hidden;
48
+ this.#popup.hidden = this.#backdrop.hidden = !open;
49
+ this.#button.setAttribute("aria-expanded", String(open));
50
+ if (open) this.#popup.firstElementChild.focus();
51
+ };
52
+ this.#backdrop.onclick = () => this.close();
53
+ this.#popup.onkeydown = event => {
54
+ if (event.key === "Tab") {
55
+ this.close();
56
+ return;
57
+ }
58
+ const items = [...this.#popup.children];
59
+ const index = items.indexOf(root.activeElement);
60
+ const offsets = { ArrowDown: 1, ArrowUp: -1 };
61
+ if (event.key in offsets) {
62
+ event.preventDefault();
63
+ items[(index + offsets[event.key] + items.length) % items.length].focus();
64
+ }
65
+ };
66
+ document.addEventListener("keydown", this.#key);
67
+ this.update([]);
68
+ }
69
+
70
+ /**
71
+ * 同步可用操作和忙碌状态,不重建菜单触发按钮。
72
+ * Update actions and busy state without replacing the trigger button.
73
+ * @param {Array<{id: string, label: string, destructive?: boolean}>} items 操作列表 / Actions.
74
+ * @param {boolean} [disabled] 是否忙碌 / Whether operations are busy.
75
+ * @returns {void} 无返回值 / No return value.
76
+ */
77
+ update(items, disabled = false) {
78
+ this.close();
79
+ this.#button.disabled = disabled || items.length === 0;
80
+ this.#popup.replaceChildren(
81
+ ...items.map(item => {
82
+ const button = this.#document.createElement("button");
83
+ button.type = "button";
84
+ button.setAttribute("role", "menuitem");
85
+ button.textContent = item.label;
86
+ button.toggleAttribute("data-danger", Boolean(item.destructive));
87
+ button.onclick = () => {
88
+ this.close();
89
+ this.#select(item.id);
90
+ };
91
+ return button;
92
+ }),
93
+ );
94
+ }
95
+
96
+ /**
97
+ * 关闭菜单。
98
+ * Close the menu.
99
+ * @returns {void} 无返回值 / No return value.
100
+ */
101
+ close() {
102
+ this.#popup.hidden = this.#backdrop.hidden = true;
103
+ this.#button.setAttribute("aria-expanded", "false");
104
+ }
105
+
106
+ /**
107
+ * 移除监听器与节点。
108
+ * Remove listeners and elements.
109
+ * @returns {void} 无返回值 / No return value.
110
+ */
111
+ destroy() {
112
+ this.#document.removeEventListener("keydown", this.#key);
113
+ this.element.remove();
114
+ }
115
+ }
116
+
1
117
  /**
2
118
  * 统一解析模块页的资源地址:Header 优先于查询参数,再使用模块约定。
3
119
  * Resolve module resource locations: headers override query parameters and module conventions.
@@ -27,9 +143,13 @@ class ModuleFrame extends EventTarget {
27
143
  #abort = () => this.destroy();
28
144
  #state;
29
145
  #change = event => {
30
- this.#state = event.detail;
146
+ this.#state = { ...event.detail, actions: event.detail.actions ?? [] };
31
147
  this.dispatchEvent(new Event("change"));
32
148
  };
149
+ #confirmation = event => {
150
+ const request = new CustomEvent("confirm", { cancelable: true, detail: event.detail });
151
+ if (!this.dispatchEvent(request)) event.preventDefault();
152
+ };
33
153
 
34
154
  /**
35
155
  * 建立 iframe 与请求输入;调用方挂载 element 后调用 load。
@@ -46,7 +166,8 @@ class ModuleFrame extends EventTarget {
46
166
  this.element.title = `${inputs.module} 设置`;
47
167
  this.element.dataset.preferencePanes = JSON.stringify(inputs);
48
168
  this.element.addEventListener("preferencepanes:change", this.#change);
49
- this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true };
169
+ this.element.addEventListener("preferencepanes:confirm", this.#confirmation);
170
+ this.#state = { title: inputs.module, module: inputs.module, busy: false, canGoBack: true, actions: [] };
50
171
  options.signal?.addEventListener("abort", this.#abort, { once: true });
51
172
  }
52
173
 
@@ -86,6 +207,17 @@ class ModuleFrame extends EventTarget {
86
207
  if (!this.#state.busy && this.#state.canGoBack) this.element.contentWindow.history.back();
87
208
  }
88
209
 
210
+ /**
211
+ * 向模块发送菜单操作,不让宿主访问内部 DOM 或存储客户端。
212
+ * Dispatch a menu action without host access to internal DOM or the storage client.
213
+ * @param {string} id 当前可用操作 / Available action identifier.
214
+ * @returns {void} 无返回值 / No return value.
215
+ */
216
+ perform(id) {
217
+ if (this.#state.busy || !this.#state.actions.some(action => action.id === id)) throw new Error("Action is not available");
218
+ this.element.dispatchEvent(new CustomEvent("preferencepanes:action", { detail: id }));
219
+ }
220
+
89
221
  /**
90
222
  * 取消加载与事件订阅;节点保留到 Navigation 的退出动画结束。
91
223
  * Cancel loading and subscriptions; Navigation retains the node until its exit animation ends.
@@ -95,6 +227,94 @@ class ModuleFrame extends EventTarget {
95
227
  this.#controller.abort();
96
228
  this.#options.signal?.removeEventListener("abort", this.#abort);
97
229
  this.element.removeEventListener("preferencepanes:change", this.#change);
230
+ this.element.removeEventListener("preferencepanes:confirm", this.#confirmation);
231
+ }
232
+ }
233
+
234
+ /**
235
+ * 模块入口的固定状态行,只通过 HEAD 探测安装状态和业务版本。
236
+ * Fixed module status row, probing installation and business version with HEAD only.
237
+ */
238
+ class ModuleStatus extends EventTarget {
239
+ #element;
240
+ #controller;
241
+ #state = { status: "checking", version: null };
242
+
243
+ /**
244
+ * 绑定调用方提供的状态行。
245
+ * Bind a caller-owned status row.
246
+ * @param {HTMLElement} element 状态文字容器 / Status text container.
247
+ */
248
+ constructor(element) {
249
+ super();
250
+ this.#element = element;
251
+ this.#render("checking");
252
+ }
253
+
254
+ /**
255
+ * 当前安装状态与业务版本。
256
+ * Current installation state and business version.
257
+ */
258
+ get state() {
259
+ return { ...this.#state };
260
+ }
261
+
262
+ /**
263
+ * 每次进入重新探测,取消旧请求并忽略其迟到结果。
264
+ * Reprobe on entry, cancelling old requests and ignoring late results.
265
+ * @param {string | URL} url 配置 Mock 地址 / Configuration Mock URL.
266
+ * @returns {Promise<void>} 探测完成 / Probe completion.
267
+ */
268
+ async check(url) {
269
+ this.#controller?.abort();
270
+ const controller = (this.#controller = new AbortController());
271
+ this.#render("checking");
272
+ const timer = setTimeout(() => controller.abort(), 3500);
273
+ try {
274
+ const response = await fetch(url, { method: "HEAD", cache: "no-store", credentials: "omit", signal: controller.signal });
275
+ if (controller !== this.#controller) return;
276
+ const version = response.headers.get("X-PreferencePanes-Version")?.trim() || null;
277
+ this.#render(response.status === 200 ? "installed" : "missing", version);
278
+ } catch {
279
+ if (controller === this.#controller) this.#render("missing");
280
+ } finally {
281
+ clearTimeout(timer);
282
+ }
283
+ }
284
+
285
+ /**
286
+ * 更新状态标签,缺少版本时不伪造版本号。
287
+ * Render the label without inventing a missing version.
288
+ * @param {"checking" | "installed" | "missing"} status 状态 / State.
289
+ * @param {string | null} [version] 业务版本 / Business version.
290
+ * @returns {void} 无返回值 / No return value.
291
+ */
292
+ #render(status, version = null) {
293
+ this.#state = { status, version: status === "installed" ? version : null };
294
+ switch (status) {
295
+ case "checking":
296
+ this.#element.textContent = "检测中";
297
+ break;
298
+ case "installed":
299
+ this.#element.textContent = version ?? "版本未知";
300
+ break;
301
+ case "missing":
302
+ this.#element.textContent = "未安装";
303
+ break;
304
+ }
305
+ this.#element.dataset.state = status;
306
+ this.#element.title = this.#element.textContent;
307
+ this.dispatchEvent(new Event("change"));
308
+ }
309
+
310
+ /**
311
+ * 释放尚未完成的探测。
312
+ * Release pending probes.
313
+ * @returns {void} 无返回值 / No return value.
314
+ */
315
+ destroy() {
316
+ this.#controller?.abort();
317
+ this.#controller = undefined;
98
318
  }
99
319
  }
100
320
 
@@ -257,4 +477,4 @@ class Navigation extends EventTarget {
257
477
  }
258
478
  }
259
479
 
260
- export { ModuleFrame, Navigation };
480
+ export { ActionMenu, ModuleFrame, ModuleStatus, Navigation };