@nsnanocat/preference-panes 0.9.9 → 0.9.11

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.
@@ -22,8 +22,8 @@ class BoxJS {
22
22
  */
23
23
  constructor(input) {
24
24
  if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
25
- this.document = JSON.parse(JSON.stringify(input));
26
- const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);
25
+ const document = JSON.parse(JSON.stringify(input));
26
+ const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
27
27
  if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
28
28
  this.modules = new Map();
29
29
  for (const app of apps) {
@@ -31,7 +31,7 @@ class BoxJS {
31
31
  for (const entry of app.settings) {
32
32
  if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
33
33
  if (!entry.id.startsWith("@")) {
34
- if (Array.isArray(this.document)) throw new TypeError("BoxJS settings require @root.path IDs");
34
+ if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
35
35
  continue;
36
36
  }
37
37
  const [storageKey, ...parts] = entry.id.slice(1).split(".");
@@ -48,24 +48,10 @@ class BoxJS {
48
48
  target.owners.add(app);
49
49
  }
50
50
  }
51
- this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);
51
+ this.metadata = metadata(Array.isArray(document) ? {} : document);
52
52
  for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
53
53
  }
54
54
 
55
- /**
56
- * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
57
- * Select a module's native BoxJS while retaining owning-app metadata.
58
- * @param {string} module 模块标识 / Module identifier.
59
- * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
60
- */
61
- select(module) {
62
- const target = this.modules.get(module);
63
- if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
64
- if (Array.isArray(this.document)) return target.entries;
65
- const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
66
- return this.document.apps ? { ...this.document, apps } : apps[0];
67
- }
68
-
69
55
  /**
70
56
  * 取得本次导入的唯一模块,避免把模块数据变成项目目录。
71
57
  * Get the single imported module without turning module data into a project directory.
@@ -163,22 +149,6 @@ function resourceURL(value) {
163
149
  return url.href;
164
150
  }
165
151
 
166
- /**
167
- * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。
168
- * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.
169
- * @param {import("../index.js").BoxJSMetadata} metadata 展示信息 / Presentation metadata.
170
- * @param {string} className 样式 / CSS class.
171
- * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.
172
- */
173
- function icon(metadata, className) {
174
- const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
175
- if (!source) return null;
176
- const image = element("img", className);
177
- image.src = resourceURL(source);
178
- image.alt = "";
179
- return image;
180
- }
181
-
182
152
  /**
183
153
  * 共享加载失败视图,不创建配置表单或数据读取。
184
154
  * Share a load-error view without creating controls or reading settings.
@@ -213,20 +183,21 @@ function requestConfirmation(host, message) {
213
183
  });
214
184
  }
215
185
 
216
- var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n/* 模块图标位于固定搜索工具栏左侧;嵌入宿主仍可使用纯文字原生标题栏。\n * Keep the module icon beside the fixed search field while embedded hosts use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 36px;\n height: 36px;\n flex: none;\n}\n.pp-module-logo:not(:empty) {\n display: block;\n}\n.pp-module-logo img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n/* 固定工具区与滚动视口共用弹性布局,不按工具栏高度拼接页面偏移。\n * Fixed tools and the scrolling viewport share a flex layout without toolbar-height offsets. */\n.pp-toolbar {\n flex: none;\n min-width: 0;\n padding: 12px max(16px, calc((100% - 688px) / 2));\n display: flex;\n align-items: center;\n gap: 12px;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-viewport {\n position: relative;\n flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-search {\n flex: 1;\n width: 100%;\n min-width: 0;\n margin: 0;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
186
+ var defaults = "/* 通用默认样式只使用 pp 命名空间;项目可通过 CSS 输入覆盖变量和组件。\n * Generic defaults use only the pp namespace; projects may override variables and components through CSS input. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-field: #f1f2f3;\n --pp-border: #e3e5e7;\n --pp-muted: #797f87;\n --pp-accent: #1677ff;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n max-width: 100vw;\n min-width: 0;\n height: 100vh;\n overflow: hidden;\n}\n\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #f1f2f3;\n --pp-background: #0d0e0f;\n --pp-surface: #18191c;\n --pp-field: #2f3238;\n --pp-border: #2f3238;\n --pp-muted: #9499a0;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n flex: none;\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: relative;\n z-index: 2;\n}\n.pp-title {\n flex: 1;\n text-align: center;\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n flex: 1;\n min-width: 0;\n min-height: 0;\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n@supports (height: 100dvh) {\n .pp-panel {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page,\n.pp-cache-page {\n position: absolute;\n inset: 0;\n min-width: 0;\n overflow-x: hidden;\n overflow-y: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom) + var(--pp-keyboard-height, 0px));\n scroll-padding-bottom: var(--pp-keyboard-height, 0px);\n background: var(--pp-background);\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-editor {\n flex: none;\n width: 45%;\n min-width: 0;\n min-height: 36px;\n padding: 8px 10px;\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-field);\n border: 0;\n border-radius: 6px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-editor {\n width: 100%;\n margin-top: 10px;\n}\n.pp-panel [hidden] {\n display: none !important;\n}\n.pp-label {\n flex: 1;\n min-width: 0;\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n margin-right: 16px;\n}\n.pp-field-name {\n color: var(--pp-text);\n font-size: 15px;\n}\n.pp-field-description {\n margin-top: 2px;\n color: var(--pp-muted);\n font-size: 12px;\n}\n.pp-group {\n margin-top: 16px;\n}\n.pp-group-title {\n margin: 0 0 8px;\n color: var(--pp-muted);\n font-size: 15px;\n font-weight: 400;\n}\n.pp-row {\n min-width: 0;\n min-height: 48px;\n padding: 16px;\n display: flex;\n align-items: center;\n justify-content: space-between;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n}\n.pp-rows > :last-child {\n border-bottom: 0 !important;\n}\n.pp-switch {\n flex: none;\n accent-color: var(--pp-accent);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-cache {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n";
217
187
 
218
188
  /**
219
- * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
220
- * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
189
+ * 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
190
+ * Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
221
191
  */
222
192
  class ActionMenu {
223
193
  #button;
224
- #popup;
225
- #backdrop;
194
+ #layer;
195
+ #items;
226
196
  #select;
227
197
  #document;
198
+ #disabled = true;
228
199
  #key = event => {
229
- if (event.key === "Escape" && !this.#popup.hidden) {
200
+ if (event.key === "Escape" && !this.#layer.hidden) {
230
201
  event.preventDefault();
231
202
  this.close();
232
203
  this.#button.focus();
@@ -242,44 +213,56 @@ class ActionMenu {
242
213
  this.#document = document;
243
214
  this.#select = select;
244
215
  this.element = document.createElement("span");
245
- const root = this.element.attachShadow({ mode: "open" });
246
- root.innerHTML = `<style>
247
- :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
248
- :host([hidden]),[hidden]{display:none!important}
249
- button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
216
+ const triggerRoot = this.element.attachShadow({ mode: "open" });
217
+ triggerRoot.innerHTML = `<style>
218
+ :host{display:inline-flex;width:44px;height:44px;color:inherit}
219
+ :host([hidden]){display:none!important}
220
+ button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
250
221
  button:disabled{opacity:.4;cursor:default}
251
222
  button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
252
- #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
253
223
  svg{display:block;width:24px;height:24px;fill:currentColor}
254
- #backdrop{position:fixed;inset:0;z-index:1}
255
- #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}
256
- #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}
257
- #items button:hover{background:#8882}
258
- #items button[data-danger]{color:#e45656}
259
- </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>`;
260
- this.#button = root.querySelector("#trigger");
261
- this.#popup = root.querySelector("#items");
262
- this.#backdrop = root.querySelector("#backdrop");
263
- this.#button.onclick = () => {
264
- const open = this.#popup.hidden;
265
- this.#popup.hidden = this.#backdrop.hidden = !open;
266
- this.#button.setAttribute("aria-expanded", String(open));
267
- if (open) this.#popup.firstElementChild.focus();
224
+ </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><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>`;
225
+ this.#button = triggerRoot.querySelector("button");
226
+ this.#layer = document.createElement("span");
227
+ const layerRoot = this.#layer.attachShadow({ mode: "open" });
228
+ layerRoot.innerHTML = `<style>
229
+ :host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
230
+ :host([hidden]){display:none!important}
231
+ *,*::before,*::after{box-sizing:border-box}
232
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
233
+ button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
234
+ #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
235
+ #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
236
+ #items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
237
+ #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
238
+ #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
239
+ #items button+button{border-top:1px solid var(--pp-border,#8884)}
240
+ #items button[data-danger]{color:var(--pp-danger,#e45656)}
241
+ #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
242
+ @keyframes pp-fade-in{from{opacity:0}}
243
+ @keyframes pp-sheet-in{from{transform:translateY(100%)}}
244
+ @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
245
+ </style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
246
+ this.#items = layerRoot.querySelector("#items");
247
+ this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
248
+ layerRoot.querySelector("#backdrop").onclick = () => {
249
+ this.close();
250
+ this.#button.focus();
268
251
  };
269
- this.#backdrop.onclick = () => this.close();
270
- this.#popup.onkeydown = event => {
271
- if (event.key === "Tab") {
272
- this.close();
273
- return;
274
- }
275
- const items = [...this.#popup.children];
276
- const index = items.indexOf(root.activeElement);
252
+ layerRoot.querySelector("#cancel").onclick = () => {
253
+ this.close();
254
+ this.#button.focus();
255
+ };
256
+ this.#items.onkeydown = event => {
257
+ const items = [...this.#items.children];
258
+ const index = items.indexOf(layerRoot.activeElement);
277
259
  const offsets = { ArrowDown: 1, ArrowUp: -1 };
278
260
  if (event.key in offsets) {
279
261
  event.preventDefault();
280
262
  items[(index + offsets[event.key] + items.length) % items.length].focus();
281
263
  }
282
264
  };
265
+ document.body.append(this.#layer);
283
266
  document.addEventListener("keydown", this.#key);
284
267
  this.update([]);
285
268
  }
@@ -293,8 +276,9 @@ class ActionMenu {
293
276
  */
294
277
  update(items, disabled = false) {
295
278
  this.close();
296
- this.#button.disabled = disabled || items.length === 0;
297
- this.#popup.replaceChildren(
279
+ this.#disabled = disabled || items.length === 0;
280
+ this.#button.disabled = this.#disabled;
281
+ this.#items.replaceChildren(
298
282
  ...items.map(item => {
299
283
  const button = this.#document.createElement("button");
300
284
  button.type = "button";
@@ -310,13 +294,30 @@ class ActionMenu {
310
294
  );
311
295
  }
312
296
 
297
+ /**
298
+ * 打开当前操作菜单。
299
+ * Open the current action sheet.
300
+ * @returns {void} 无返回值 / No return value.
301
+ */
302
+ open() {
303
+ if (this.#disabled) return;
304
+ const style = getComputedStyle(this.element);
305
+ for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
306
+ const value = style.getPropertyValue(property);
307
+ if (value) this.#layer.style.setProperty(property, value);
308
+ }
309
+ this.#layer.hidden = false;
310
+ this.#button.setAttribute("aria-expanded", "true");
311
+ this.#items.firstElementChild.focus();
312
+ }
313
+
313
314
  /**
314
315
  * 关闭菜单。
315
316
  * Close the menu.
316
317
  * @returns {void} 无返回值 / No return value.
317
318
  */
318
319
  close() {
319
- this.#popup.hidden = this.#backdrop.hidden = true;
320
+ this.#layer.hidden = true;
320
321
  this.#button.setAttribute("aria-expanded", "false");
321
322
  }
322
323
 
@@ -327,6 +328,7 @@ class ActionMenu {
327
328
  */
328
329
  destroy() {
329
330
  this.#document.removeEventListener("keydown", this.#key);
331
+ this.#layer.remove();
330
332
  this.element.remove();
331
333
  }
332
334
  }
@@ -334,14 +336,15 @@ class ActionMenu {
334
336
  /**
335
337
  * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
336
338
  * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
337
- * @param {unknown} config BoxJS JSON / BoxJS document.
339
+ * @param {unknown | BoxJS} config BoxJS JSON 或已解析目录 / BoxJS document or parsed catalog.
338
340
  * @param {string} module API 第一段模块名 / First API path segment.
339
341
  * @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
340
342
  * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
341
343
  */
342
344
  function normalizeBoxJs(config, module) {
343
345
  validatePathParts([module]);
344
- const target = new BoxJS(config).modules.get(module);
346
+ const catalog = config instanceof BoxJS ? config : new BoxJS(config);
347
+ const target = catalog.modules.get(module);
345
348
  if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
346
349
  const { entries, storageKey, metadata } = target;
347
350
  const fields = [];
@@ -574,7 +577,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
574
577
  const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
575
578
  sessions.set(module, state);
576
579
  try {
577
- const definition = normalizeBoxJs(catalog.select(module), module);
580
+ const definition = normalizeBoxJs(catalog, module);
578
581
  const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
579
582
  let subtree = response.status === 404 ? {} : await response.json();
580
583
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
@@ -838,17 +841,10 @@ function mountPanel(root, catalog) {
838
841
  const menu = new ActionMenu(id => runAction(id));
839
842
  const trailing = element("span", "pp-nav-spacer");
840
843
  trailing.append(menu.element);
841
- const logo = element("span", "pp-module-logo");
842
- logo.setAttribute("aria-hidden", "true");
843
- const image = icon(catalog.module.metadata, "");
844
- if (image) logo.append(image);
845
- const toolbar = element("div", "pp-toolbar");
846
- toolbar.setAttribute("role", "search");
847
- toolbar.hidden = true;
848
844
  const viewport = element("div", "pp-viewport");
849
845
  let toast;
850
846
  header.append(back, heading, trailing);
851
- shell.append(header, toolbar, viewport);
847
+ shell.append(header, viewport);
852
848
  root.append(shell);
853
849
  // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
854
850
  // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
@@ -939,7 +935,6 @@ function mountPanel(root, catalog) {
939
935
  async function open(module) {
940
936
  const version = ++generation;
941
937
  active = module;
942
- toolbar.hidden = true;
943
938
  back.disabled = window.history.length <= 1;
944
939
  heading.textContent = module;
945
940
  publishNavigation();
@@ -962,14 +957,6 @@ function mountPanel(root, catalog) {
962
957
  const { definition, values } = client.snapshot(active);
963
958
  heading.textContent = definition.metadata?.name || active;
964
959
  const view = element("section", "pp-fields");
965
- const search = element("input", "");
966
- search.type = "search";
967
- search.placeholder = "搜索设置项";
968
- search.setAttribute("aria-label", "搜索设置");
969
- const searchField = fieldControl(search);
970
- searchField.classList.add("pp-search");
971
- toolbar.replaceChildren(logo, searchField);
972
- const searchRows = [];
973
960
  /**
974
961
  * 挂载后执行的多行高度更新
975
962
  * Textarea sizing callbacks run after mounting.
@@ -988,7 +975,6 @@ function mountPanel(root, catalog) {
988
975
  */
989
976
  const updateNavigation = () => {
990
977
  const editor = editors.get(navigation.current);
991
- toolbar.hidden = Boolean(navigation.current);
992
978
  heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
993
979
  back.disabled = saving || !navigation.canGoBack;
994
980
  publishNavigation();
@@ -1213,18 +1199,7 @@ function mountPanel(root, catalog) {
1213
1199
  });
1214
1200
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
1215
1201
  groups.get(group).append(row);
1216
- searchRows.push({ row, text: [field.name, field.key, field.description, ...(field.options ?? []).map(option => option.label)].join(" ").toLocaleLowerCase() });
1217
1202
  }
1218
- const empty = element("p", "pp-description", "没有匹配的设置项");
1219
- empty.hidden = true;
1220
- empty.setAttribute("role", "status");
1221
- view.append(empty);
1222
- search.oninput = () => {
1223
- const words = search.value.trim().toLocaleLowerCase().split(/\s+/);
1224
- for (const { row, text } of searchRows) row.hidden = !words.every(word => text.includes(word));
1225
- for (const rows of groups.values()) rows.parentElement.hidden = [...rows.children].every(row => row.hidden);
1226
- empty.hidden = searchRows.some(({ row }) => !row.hidden);
1227
- };
1228
1203
  const cachePage = element("section", "pp-cache-page");
1229
1204
  const output = element("pre", "pp-cache");
1230
1205
  output.textContent = "暂无缓存";
@@ -1309,7 +1284,7 @@ function mountPanel(root, catalog) {
1309
1284
  */
1310
1285
  function mount(boxjs, css = "") {
1311
1286
  if (typeof css !== "string") throw new TypeError("CSS must be a string");
1312
- const catalog = new BoxJS(boxjs);
1287
+ const catalog = boxjs instanceof BoxJS ? boxjs : new BoxJS(boxjs);
1313
1288
  const metadata = catalog.module.metadata;
1314
1289
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1315
1290
  if (image) resourceURL(image);
@@ -1411,9 +1386,9 @@ async function start() {
1411
1386
  });
1412
1387
  const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
1413
1388
  if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
1414
- const boxjs = await data.json();
1415
- if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
1416
- view = mount(boxjs, style ? await style.text() : "");
1389
+ const catalog = new BoxJS(await data.json());
1390
+ if (catalog.module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
1391
+ view = mount(catalog, style ? await style.text() : "");
1417
1392
  } catch (error) {
1418
1393
  document.querySelector("#preferences").replaceChildren(errorView(error, start));
1419
1394
  }
@@ -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.9.9"></script>
12
+ <script type="module" src="/settings/assets/app.mjs?v=0.9.11"></script>
13
13
  </body>
14
14
  </html>
@@ -1,15 +1,16 @@
1
1
  /**
2
- * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
3
- * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
2
+ * 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
3
+ * Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
4
4
  */
5
5
  class ActionMenu {
6
6
  #button;
7
- #popup;
8
- #backdrop;
7
+ #layer;
8
+ #items;
9
9
  #select;
10
10
  #document;
11
+ #disabled = true;
11
12
  #key = event => {
12
- if (event.key === "Escape" && !this.#popup.hidden) {
13
+ if (event.key === "Escape" && !this.#layer.hidden) {
13
14
  event.preventDefault();
14
15
  this.close();
15
16
  this.#button.focus();
@@ -25,44 +26,56 @@ class ActionMenu {
25
26
  this.#document = document;
26
27
  this.#select = select;
27
28
  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}
29
+ const triggerRoot = this.element.attachShadow({ mode: "open" });
30
+ triggerRoot.innerHTML = `<style>
31
+ :host{display:inline-flex;width:44px;height:44px;color:inherit}
32
+ :host([hidden]){display:none!important}
33
+ button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
33
34
  button:disabled{opacity:.4;cursor:default}
34
35
  button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
35
- #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
36
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();
37
+ </style><button type="button" aria-label="更多操作" aria-haspopup="menu" aria-expanded="false"><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>`;
38
+ this.#button = triggerRoot.querySelector("button");
39
+ this.#layer = document.createElement("span");
40
+ const layerRoot = this.#layer.attachShadow({ mode: "open" });
41
+ layerRoot.innerHTML = `<style>
42
+ :host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
43
+ :host([hidden]){display:none!important}
44
+ *,*::before,*::after{box-sizing:border-box}
45
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
46
+ button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
47
+ #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
48
+ #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:100%;max-width:540px;max-height:calc(100% - 24px);margin:auto;padding:8px 8px calc(8px + env(safe-area-inset-bottom));animation:pp-sheet-in .22s cubic-bezier(.2,.8,.2,1)}
49
+ #items,#cancel{overflow:hidden;background:var(--pp-surface,Canvas);border:1px solid var(--pp-border,#8884);border-radius:14px;box-shadow:0 8px 28px #0004}
50
+ #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
51
+ #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
52
+ #items button+button{border-top:1px solid var(--pp-border,#8884)}
53
+ #items button[data-danger]{color:var(--pp-danger,#e45656)}
54
+ #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
55
+ @keyframes pp-fade-in{from{opacity:0}}
56
+ @keyframes pp-sheet-in{from{transform:translateY(100%)}}
57
+ @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
58
+ </style><button id="backdrop" type="button" tabindex="-1" aria-label="关闭菜单"></button><section id="sheet" role="dialog" aria-modal="true" aria-label="更多操作"><div id="items" role="menu"></div><button id="cancel" type="button">取消</button></section>`;
59
+ this.#items = layerRoot.querySelector("#items");
60
+ this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
61
+ layerRoot.querySelector("#backdrop").onclick = () => {
62
+ this.close();
63
+ this.#button.focus();
51
64
  };
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);
65
+ layerRoot.querySelector("#cancel").onclick = () => {
66
+ this.close();
67
+ this.#button.focus();
68
+ };
69
+ this.#items.onkeydown = event => {
70
+ const items = [...this.#items.children];
71
+ const index = items.indexOf(layerRoot.activeElement);
60
72
  const offsets = { ArrowDown: 1, ArrowUp: -1 };
61
73
  if (event.key in offsets) {
62
74
  event.preventDefault();
63
75
  items[(index + offsets[event.key] + items.length) % items.length].focus();
64
76
  }
65
77
  };
78
+ document.body.append(this.#layer);
66
79
  document.addEventListener("keydown", this.#key);
67
80
  this.update([]);
68
81
  }
@@ -76,8 +89,9 @@ class ActionMenu {
76
89
  */
77
90
  update(items, disabled = false) {
78
91
  this.close();
79
- this.#button.disabled = disabled || items.length === 0;
80
- this.#popup.replaceChildren(
92
+ this.#disabled = disabled || items.length === 0;
93
+ this.#button.disabled = this.#disabled;
94
+ this.#items.replaceChildren(
81
95
  ...items.map(item => {
82
96
  const button = this.#document.createElement("button");
83
97
  button.type = "button";
@@ -93,13 +107,30 @@ class ActionMenu {
93
107
  );
94
108
  }
95
109
 
110
+ /**
111
+ * 打开当前操作菜单。
112
+ * Open the current action sheet.
113
+ * @returns {void} 无返回值 / No return value.
114
+ */
115
+ open() {
116
+ if (this.#disabled) return;
117
+ const style = getComputedStyle(this.element);
118
+ for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
119
+ const value = style.getPropertyValue(property);
120
+ if (value) this.#layer.style.setProperty(property, value);
121
+ }
122
+ this.#layer.hidden = false;
123
+ this.#button.setAttribute("aria-expanded", "true");
124
+ this.#items.firstElementChild.focus();
125
+ }
126
+
96
127
  /**
97
128
  * 关闭菜单。
98
129
  * Close the menu.
99
130
  * @returns {void} 无返回值 / No return value.
100
131
  */
101
132
  close() {
102
- this.#popup.hidden = this.#backdrop.hidden = true;
133
+ this.#layer.hidden = true;
103
134
  this.#button.setAttribute("aria-expanded", "false");
104
135
  }
105
136
 
@@ -110,6 +141,7 @@ class ActionMenu {
110
141
  */
111
142
  destroy() {
112
143
  this.#document.removeEventListener("keydown", this.#key);
144
+ this.#layer.remove();
113
145
  this.element.remove();
114
146
  }
115
147
  }