@nsnanocat/preference-panes 0.9.8 → 0.9.10

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.
@@ -1,4 +1,4 @@
1
- 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 height: 100vh;\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 module icons in content so embedded hosts can use text-only native titles. */\n.pp-module-logo {\n display: none;\n width: 64px;\n height: 64px;\n margin: 8px auto 20px;\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 padding: 12px max(16px, calc((100% - 688px) / 2));\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-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 overflow: 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 width: 100%;\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-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";
1
+ 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";
2
2
 
3
3
  /**
4
4
  * 校验原始路径片段,不进行 URL 编码转换。
@@ -24,8 +24,8 @@ class BoxJS {
24
24
  */
25
25
  constructor(input) {
26
26
  if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
27
- this.document = JSON.parse(JSON.stringify(input));
28
- const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);
27
+ const document = JSON.parse(JSON.stringify(input));
28
+ const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
29
29
  if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
30
30
  this.modules = new Map();
31
31
  for (const app of apps) {
@@ -33,7 +33,7 @@ class BoxJS {
33
33
  for (const entry of app.settings) {
34
34
  if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
35
35
  if (!entry.id.startsWith("@")) {
36
- if (Array.isArray(this.document)) throw new TypeError("BoxJS settings require @root.path IDs");
36
+ if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
37
37
  continue;
38
38
  }
39
39
  const [storageKey, ...parts] = entry.id.slice(1).split(".");
@@ -50,24 +50,10 @@ class BoxJS {
50
50
  target.owners.add(app);
51
51
  }
52
52
  }
53
- this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);
53
+ this.metadata = metadata(Array.isArray(document) ? {} : document);
54
54
  for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
55
55
  }
56
56
 
57
- /**
58
- * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
59
- * Select a module's native BoxJS while retaining owning-app metadata.
60
- * @param {string} module 模块标识 / Module identifier.
61
- * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
62
- */
63
- select(module) {
64
- const target = this.modules.get(module);
65
- if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
66
- if (Array.isArray(this.document)) return target.entries;
67
- const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
68
- return this.document.apps ? { ...this.document, apps } : apps[0];
69
- }
70
-
71
57
  /**
72
58
  * 取得本次导入的唯一模块,避免把模块数据变成项目目录。
73
59
  * Get the single imported module without turning module data into a project directory.
@@ -147,22 +133,6 @@ function resourceURL(value) {
147
133
  return url.href;
148
134
  }
149
135
 
150
- /**
151
- * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。
152
- * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.
153
- * @param {import("../index.js").BoxJSMetadata} metadata 展示信息 / Presentation metadata.
154
- * @param {string} className 样式 / CSS class.
155
- * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.
156
- */
157
- function icon(metadata, className) {
158
- const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
159
- if (!source) return null;
160
- const image = element("img", className);
161
- image.src = resourceURL(source);
162
- image.alt = "";
163
- return image;
164
- }
165
-
166
136
  /**
167
137
  * 共享加载失败视图,不创建配置表单或数据读取。
168
138
  * Share a load-error view without creating controls or reading settings.
@@ -198,17 +168,18 @@ function requestConfirmation(host, message) {
198
168
  }
199
169
 
200
170
  /**
201
- * 标题栏共用三点菜单;Shadow DOM 隔离项目样式,保留继承的主题色。
202
- * Shared title-bar overflow menu; Shadow DOM isolates layout while inheriting theme colors.
171
+ * 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
172
+ * Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
203
173
  */
204
174
  class ActionMenu {
205
175
  #button;
206
- #popup;
207
- #backdrop;
176
+ #layer;
177
+ #items;
208
178
  #select;
209
179
  #document;
180
+ #disabled = true;
210
181
  #key = event => {
211
- if (event.key === "Escape" && !this.#popup.hidden) {
182
+ if (event.key === "Escape" && !this.#layer.hidden) {
212
183
  event.preventDefault();
213
184
  this.close();
214
185
  this.#button.focus();
@@ -224,44 +195,55 @@ class ActionMenu {
224
195
  this.#document = document;
225
196
  this.#select = select;
226
197
  this.element = document.createElement("span");
227
- const root = this.element.attachShadow({ mode: "open" });
228
- root.innerHTML = `<style>
229
- :host{display:inline-flex;position:relative;width:44px;height:44px;color:inherit}
230
- :host([hidden]),[hidden]{display:none!important}
231
- button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
198
+ const triggerRoot = this.element.attachShadow({ mode: "open" });
199
+ triggerRoot.innerHTML = `<style>
200
+ :host{display:inline-flex;width:44px;height:44px;color:inherit}
201
+ :host([hidden]){display:none!important}
202
+ button{width:44px;height:44px;padding:10px;font:inherit;cursor:pointer;border:0;color:inherit;background:none}
232
203
  button:disabled{opacity:.4;cursor:default}
233
204
  button:focus-visible{outline:2px solid currentColor;outline-offset:-3px}
234
- #trigger{width:44px;height:44px;padding:10px;position:relative;z-index:3}
235
205
  svg{display:block;width:24px;height:24px;fill:currentColor}
236
- #backdrop{position:fixed;inset:0;z-index:1}
237
- #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}
238
- #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}
239
- #items button:hover{background:#8882}
240
- #items button[data-danger]{color:#e45656}
241
- </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>`;
242
- this.#button = root.querySelector("#trigger");
243
- this.#popup = root.querySelector("#items");
244
- this.#backdrop = root.querySelector("#backdrop");
245
- this.#button.onclick = () => {
246
- const open = this.#popup.hidden;
247
- this.#popup.hidden = this.#backdrop.hidden = !open;
248
- this.#button.setAttribute("aria-expanded", String(open));
249
- if (open) this.#popup.firstElementChild.focus();
206
+ </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>`;
207
+ this.#button = triggerRoot.querySelector("button");
208
+ this.#layer = document.createElement("span");
209
+ const layerRoot = this.#layer.attachShadow({ mode: "open" });
210
+ layerRoot.innerHTML = `<style>
211
+ :host{position:fixed;inset:0;z-index:2147483647;color:var(--pp-text,CanvasText);font:16px/1.4 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif}
212
+ :host([hidden]){display:none!important}
213
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
214
+ button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
215
+ #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
216
+ #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:min(100%,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)}
217
+ #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}
218
+ #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
219
+ #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
220
+ #items button+button{border-top:1px solid var(--pp-border,#8884)}
221
+ #items button[data-danger]{color:var(--pp-danger,#e45656)}
222
+ #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
223
+ @keyframes pp-fade-in{from{opacity:0}}
224
+ @keyframes pp-sheet-in{from{transform:translateY(100%)}}
225
+ @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
226
+ </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>`;
227
+ this.#items = layerRoot.querySelector("#items");
228
+ this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
229
+ layerRoot.querySelector("#backdrop").onclick = () => {
230
+ this.close();
231
+ this.#button.focus();
250
232
  };
251
- this.#backdrop.onclick = () => this.close();
252
- this.#popup.onkeydown = event => {
253
- if (event.key === "Tab") {
254
- this.close();
255
- return;
256
- }
257
- const items = [...this.#popup.children];
258
- const index = items.indexOf(root.activeElement);
233
+ layerRoot.querySelector("#cancel").onclick = () => {
234
+ this.close();
235
+ this.#button.focus();
236
+ };
237
+ this.#items.onkeydown = event => {
238
+ const items = [...this.#items.children];
239
+ const index = items.indexOf(layerRoot.activeElement);
259
240
  const offsets = { ArrowDown: 1, ArrowUp: -1 };
260
241
  if (event.key in offsets) {
261
242
  event.preventDefault();
262
243
  items[(index + offsets[event.key] + items.length) % items.length].focus();
263
244
  }
264
245
  };
246
+ document.body.append(this.#layer);
265
247
  document.addEventListener("keydown", this.#key);
266
248
  this.update([]);
267
249
  }
@@ -275,8 +257,9 @@ class ActionMenu {
275
257
  */
276
258
  update(items, disabled = false) {
277
259
  this.close();
278
- this.#button.disabled = disabled || items.length === 0;
279
- this.#popup.replaceChildren(
260
+ this.#disabled = disabled || items.length === 0;
261
+ this.#button.disabled = this.#disabled;
262
+ this.#items.replaceChildren(
280
263
  ...items.map(item => {
281
264
  const button = this.#document.createElement("button");
282
265
  button.type = "button";
@@ -292,13 +275,30 @@ class ActionMenu {
292
275
  );
293
276
  }
294
277
 
278
+ /**
279
+ * 打开当前操作菜单。
280
+ * Open the current action sheet.
281
+ * @returns {void} 无返回值 / No return value.
282
+ */
283
+ open() {
284
+ if (this.#disabled) return;
285
+ const style = getComputedStyle(this.element);
286
+ for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
287
+ const value = style.getPropertyValue(property);
288
+ if (value) this.#layer.style.setProperty(property, value);
289
+ }
290
+ this.#layer.hidden = false;
291
+ this.#button.setAttribute("aria-expanded", "true");
292
+ this.#items.firstElementChild.focus();
293
+ }
294
+
295
295
  /**
296
296
  * 关闭菜单。
297
297
  * Close the menu.
298
298
  * @returns {void} 无返回值 / No return value.
299
299
  */
300
300
  close() {
301
- this.#popup.hidden = this.#backdrop.hidden = true;
301
+ this.#layer.hidden = true;
302
302
  this.#button.setAttribute("aria-expanded", "false");
303
303
  }
304
304
 
@@ -309,6 +309,7 @@ class ActionMenu {
309
309
  */
310
310
  destroy() {
311
311
  this.#document.removeEventListener("keydown", this.#key);
312
+ this.#layer.remove();
312
313
  this.element.remove();
313
314
  }
314
315
  }
@@ -316,14 +317,15 @@ class ActionMenu {
316
317
  /**
317
318
  * 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
318
319
  * Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
319
- * @param {unknown} config BoxJS JSON / BoxJS document.
320
+ * @param {unknown | BoxJS} config BoxJS JSON 或已解析目录 / BoxJS document or parsed catalog.
320
321
  * @param {string} module API 第一段模块名 / First API path segment.
321
322
  * @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
322
323
  * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
323
324
  */
324
325
  function normalizeBoxJs(config, module) {
325
326
  validatePathParts([module]);
326
- const target = new BoxJS(config).modules.get(module);
327
+ const catalog = config instanceof BoxJS ? config : new BoxJS(config);
328
+ const target = catalog.modules.get(module);
327
329
  if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
328
330
  const { entries, storageKey, metadata } = target;
329
331
  const fields = [];
@@ -556,7 +558,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
556
558
  const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
557
559
  sessions.set(module, state);
558
560
  try {
559
- const definition = normalizeBoxJs(catalog.select(module), module);
561
+ const definition = normalizeBoxJs(catalog, module);
560
562
  const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
561
563
  let subtree = response.status === 404 ? {} : await response.json();
562
564
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
@@ -820,17 +822,10 @@ function mountPanel(root, catalog) {
820
822
  const menu = new ActionMenu(id => runAction(id));
821
823
  const trailing = element("span", "pp-nav-spacer");
822
824
  trailing.append(menu.element);
823
- const logo = element("span", "pp-module-logo");
824
- logo.setAttribute("aria-hidden", "true");
825
- const image = icon(catalog.module.metadata, "");
826
- if (image) logo.append(image);
827
- const toolbar = element("div", "pp-toolbar");
828
- toolbar.setAttribute("role", "search");
829
- toolbar.hidden = true;
830
825
  const viewport = element("div", "pp-viewport");
831
826
  let toast;
832
827
  header.append(back, heading, trailing);
833
- shell.append(header, toolbar, viewport);
828
+ shell.append(header, viewport);
834
829
  root.append(shell);
835
830
  // 嵌入模式向宿主发布导航状态,宿主不读取或修改模块内部 DOM。
836
831
  // Embedded mode publishes navigation state without host reads or mutations of the module DOM.
@@ -921,7 +916,6 @@ function mountPanel(root, catalog) {
921
916
  async function open(module) {
922
917
  const version = ++generation;
923
918
  active = module;
924
- toolbar.hidden = true;
925
919
  back.disabled = window.history.length <= 1;
926
920
  heading.textContent = module;
927
921
  publishNavigation();
@@ -944,15 +938,6 @@ function mountPanel(root, catalog) {
944
938
  const { definition, values } = client.snapshot(active);
945
939
  heading.textContent = definition.metadata?.name || active;
946
940
  const view = element("section", "pp-fields");
947
- view.append(logo);
948
- const search = element("input", "");
949
- search.type = "search";
950
- search.placeholder = "搜索设置项";
951
- search.setAttribute("aria-label", "搜索设置");
952
- const searchField = fieldControl(search);
953
- searchField.classList.add("pp-search");
954
- toolbar.replaceChildren(searchField);
955
- const searchRows = [];
956
941
  /**
957
942
  * 挂载后执行的多行高度更新
958
943
  * Textarea sizing callbacks run after mounting.
@@ -971,7 +956,6 @@ function mountPanel(root, catalog) {
971
956
  */
972
957
  const updateNavigation = () => {
973
958
  const editor = editors.get(navigation.current);
974
- toolbar.hidden = Boolean(navigation.current);
975
959
  heading.textContent = editor?.title ?? definition.metadata?.name ?? active;
976
960
  back.disabled = saving || !navigation.canGoBack;
977
961
  publishNavigation();
@@ -1196,18 +1180,7 @@ function mountPanel(root, catalog) {
1196
1180
  });
1197
1181
  if (eventName === "input") inputContainer.addEventListener("compositionend", event => event.target.dispatchEvent(new window.Event("input", { bubbles: true })));
1198
1182
  groups.get(group).append(row);
1199
- searchRows.push({ row, text: [field.name, field.key, field.description, ...(field.options ?? []).map(option => option.label)].join(" ").toLocaleLowerCase() });
1200
1183
  }
1201
- const empty = element("p", "pp-description", "没有匹配的设置项");
1202
- empty.hidden = true;
1203
- empty.setAttribute("role", "status");
1204
- view.append(empty);
1205
- search.oninput = () => {
1206
- const words = search.value.trim().toLocaleLowerCase().split(/\s+/);
1207
- for (const { row, text } of searchRows) row.hidden = !words.every(word => text.includes(word));
1208
- for (const rows of groups.values()) rows.parentElement.hidden = [...rows.children].every(row => row.hidden);
1209
- empty.hidden = searchRows.some(({ row }) => !row.hidden);
1210
- };
1211
1184
  const cachePage = element("section", "pp-cache-page");
1212
1185
  const output = element("pre", "pp-cache");
1213
1186
  output.textContent = "暂无缓存";
@@ -1292,7 +1265,7 @@ function mountPanel(root, catalog) {
1292
1265
  */
1293
1266
  function mount(boxjs, css = "") {
1294
1267
  if (typeof css !== "string") throw new TypeError("CSS must be a string");
1295
- const catalog = new BoxJS(boxjs);
1268
+ const catalog = boxjs instanceof BoxJS ? boxjs : new BoxJS(boxjs);
1296
1269
  const metadata = catalog.module.metadata;
1297
1270
  const image = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
1298
1271
  if (image) resourceURL(image);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nsnanocat/preference-panes",
3
- "version": "0.9.8",
3
+ "version": "0.9.10",
4
4
  "description": "Shared settings API runtime for JavaScript proxy modules",
5
5
  "author": "VirgilClyne <Virgil@nanocat.me>",
6
6
  "homepage": "https://NSNanoCat.github.io/preference-panes",
package/src/BoxJS.mjs CHANGED
@@ -12,8 +12,8 @@ export class BoxJS {
12
12
  */
13
13
  constructor(input) {
14
14
  if (!input || typeof input !== "object") throw new TypeError("Expected BoxJS JSON");
15
- this.document = JSON.parse(JSON.stringify(input));
16
- const apps = Array.isArray(this.document) ? [{ settings: this.document }] : (this.document.apps ?? [this.document]);
15
+ const document = JSON.parse(JSON.stringify(input));
16
+ const apps = Array.isArray(document) ? [{ settings: document }] : (document.apps ?? [document]);
17
17
  if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
18
18
  this.modules = new Map();
19
19
  for (const app of apps) {
@@ -21,7 +21,7 @@ export class BoxJS {
21
21
  for (const entry of app.settings) {
22
22
  if (typeof entry.id !== "string") throw new TypeError("BoxJS settings require string IDs");
23
23
  if (!entry.id.startsWith("@")) {
24
- if (Array.isArray(this.document)) throw new TypeError("BoxJS settings require @root.path IDs");
24
+ if (Array.isArray(document)) throw new TypeError("BoxJS settings require @root.path IDs");
25
25
  continue;
26
26
  }
27
27
  const [storageKey, ...parts] = entry.id.slice(1).split(".");
@@ -38,24 +38,10 @@ export class BoxJS {
38
38
  target.owners.add(app);
39
39
  }
40
40
  }
41
- this.metadata = metadata(Array.isArray(this.document) ? {} : this.document);
41
+ this.metadata = metadata(Array.isArray(document) ? {} : document);
42
42
  for (const target of this.modules.values()) target.metadata = target.owners.size === 1 ? metadata([...target.owners][0]) : {};
43
43
  }
44
44
 
45
- /**
46
- * 提取一个模块的原生 BoxJS,保留所属 app 的元数据。
47
- * Select a module's native BoxJS while retaining owning-app metadata.
48
- * @param {string} module 模块标识 / Module identifier.
49
- * @returns {unknown} 可直接用作配置 Mock 的 JSON / JSON suitable for a configuration Mock.
50
- */
51
- select(module) {
52
- const target = this.modules.get(module);
53
- if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
54
- if (Array.isArray(this.document)) return target.entries;
55
- const apps = [...target.owners].map(app => ({ ...app, settings: app.settings.filter(entry => target.entries.includes(entry)) }));
56
- return this.document.apps ? { ...this.document, apps } : apps[0];
57
- }
58
-
59
45
  /**
60
46
  * 取得本次导入的唯一模块,避免把模块数据变成项目目录。
61
47
  * Get the single imported module without turning module data into a project directory.
@@ -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
  export 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,55 @@ export 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
+ button{font:inherit;cursor:pointer;border:0;color:inherit;background:none}
45
+ button:focus-visible{outline:2px solid var(--pp-accent,Highlight);outline-offset:-3px}
46
+ #backdrop{position:absolute;inset:0;width:100%;height:100%;padding:0;background:#0008;animation:pp-fade-in .18s ease-out}
47
+ #sheet{position:absolute;z-index:1;left:0;right:0;bottom:0;width:min(100%,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)}
48
+ #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}
49
+ #items{max-height:calc(100vh - 116px - env(safe-area-inset-bottom));overflow-y:auto;-webkit-overflow-scrolling:touch}
50
+ #items button,#cancel{display:block;width:100%;min-height:54px;padding:14px 18px;text-align:center}
51
+ #items button+button{border-top:1px solid var(--pp-border,#8884)}
52
+ #items button[data-danger]{color:var(--pp-danger,#e45656)}
53
+ #cancel{margin-top:8px;color:var(--pp-accent,Highlight);font-weight:600}
54
+ @keyframes pp-fade-in{from{opacity:0}}
55
+ @keyframes pp-sheet-in{from{transform:translateY(100%)}}
56
+ @media (prefers-reduced-motion:reduce){#backdrop,#sheet{animation:none}}
57
+ </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>`;
58
+ this.#items = layerRoot.querySelector("#items");
59
+ this.#button.onclick = () => (this.#layer.hidden ? this.open() : this.close());
60
+ layerRoot.querySelector("#backdrop").onclick = () => {
61
+ this.close();
62
+ this.#button.focus();
51
63
  };
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);
64
+ layerRoot.querySelector("#cancel").onclick = () => {
65
+ this.close();
66
+ this.#button.focus();
67
+ };
68
+ this.#items.onkeydown = event => {
69
+ const items = [...this.#items.children];
70
+ const index = items.indexOf(layerRoot.activeElement);
60
71
  const offsets = { ArrowDown: 1, ArrowUp: -1 };
61
72
  if (event.key in offsets) {
62
73
  event.preventDefault();
63
74
  items[(index + offsets[event.key] + items.length) % items.length].focus();
64
75
  }
65
76
  };
77
+ document.body.append(this.#layer);
66
78
  document.addEventListener("keydown", this.#key);
67
79
  this.update([]);
68
80
  }
@@ -76,8 +88,9 @@ export class ActionMenu {
76
88
  */
77
89
  update(items, disabled = false) {
78
90
  this.close();
79
- this.#button.disabled = disabled || items.length === 0;
80
- this.#popup.replaceChildren(
91
+ this.#disabled = disabled || items.length === 0;
92
+ this.#button.disabled = this.#disabled;
93
+ this.#items.replaceChildren(
81
94
  ...items.map(item => {
82
95
  const button = this.#document.createElement("button");
83
96
  button.type = "button";
@@ -93,13 +106,30 @@ export class ActionMenu {
93
106
  );
94
107
  }
95
108
 
109
+ /**
110
+ * 打开当前操作菜单。
111
+ * Open the current action sheet.
112
+ * @returns {void} 无返回值 / No return value.
113
+ */
114
+ open() {
115
+ if (this.#disabled) return;
116
+ const style = getComputedStyle(this.element);
117
+ for (const property of ["--pp-text", "--pp-surface", "--pp-border", "--pp-accent", "--pp-danger"]) {
118
+ const value = style.getPropertyValue(property);
119
+ if (value) this.#layer.style.setProperty(property, value);
120
+ }
121
+ this.#layer.hidden = false;
122
+ this.#button.setAttribute("aria-expanded", "true");
123
+ this.#items.firstElementChild.focus();
124
+ }
125
+
96
126
  /**
97
127
  * 关闭菜单。
98
128
  * Close the menu.
99
129
  * @returns {void} 无返回值 / No return value.
100
130
  */
101
131
  close() {
102
- this.#popup.hidden = this.#backdrop.hidden = true;
132
+ this.#layer.hidden = true;
103
133
  this.#button.setAttribute("aria-expanded", "false");
104
134
  }
105
135
 
@@ -110,6 +140,7 @@ export class ActionMenu {
110
140
  */
111
141
  destroy() {
112
142
  this.#document.removeEventListener("keydown", this.#key);
143
+ this.#layer.remove();
113
144
  this.element.remove();
114
145
  }
115
146
  }
@@ -140,6 +140,12 @@ export class ActionMenu {
140
140
  * @returns 无返回值 / No return value.
141
141
  */
142
142
  update(items: MenuAction[], disabled?: boolean): void;
143
+ /**
144
+ * 打开当前操作菜单。
145
+ * Open the current action sheet.
146
+ * @returns 无返回值 / No return value.
147
+ */
148
+ open(): void;
143
149
  /**
144
150
  * 关闭菜单。
145
151
  * Close the menu.
@@ -35,9 +35,9 @@ async function start() {
35
35
  });
36
36
  const [data, style] = await Promise.all(resources.map(url => (url ? fetch(url, { cache: "no-store", credentials: "omit" }) : null)));
37
37
  if (data.status !== 200 || (style && style.status !== 200)) throw new Error(`HTTP ${data.status !== 200 ? data.status : style.status}`);
38
- const boxjs = await data.json();
39
- if (new BoxJS(boxjs).module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
40
- view = mount(boxjs, style ? await style.text() : "");
38
+ const catalog = new BoxJS(await data.json());
39
+ if (catalog.module.module !== inputs.module) throw new Error("Imported JSON does not match the module URL");
40
+ view = mount(catalog, style ? await style.text() : "");
41
41
  } catch (error) {
42
42
  document.querySelector("#preferences").replaceChildren(errorView(error, start));
43
43
  }
@@ -128,7 +128,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
128
128
  const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
129
129
  sessions.set(module, state);
130
130
  try {
131
- const definition = normalizeBoxJs(catalog.select(module), module);
131
+ const definition = normalizeBoxJs(catalog, module);
132
132
  const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
133
133
  let subtree = response.status === 404 ? {} : await response.json();
134
134
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
@@ -48,22 +48,6 @@ export function resourceURL(value) {
48
48
  return url.href;
49
49
  }
50
50
 
51
- /**
52
- * 展示标准 BoxJS 图标;icons 保持透明/彩色语义,不解释为亮暗版本。
53
- * Display standard BoxJS icons, preserving transparent/color rather than light/dark semantics.
54
- * @param {import("../index.js").BoxJSMetadata} metadata 展示信息 / Presentation metadata.
55
- * @param {string} className 样式 / CSS class.
56
- * @returns {HTMLImageElement | null} 图标或无图标 / Icon or no icon.
57
- */
58
- export function icon(metadata, className) {
59
- const source = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
60
- if (!source) return null;
61
- const image = element("img", className);
62
- image.src = resourceURL(source);
63
- image.alt = "";
64
- return image;
65
- }
66
-
67
51
  /**
68
52
  * 共享加载失败视图,不创建配置表单或数据读取。
69
53
  * Share a load-error view without creating controls or reading settings.