@arraypress/extension-kit 1.9.0 → 1.11.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.
Files changed (88) hide show
  1. package/README.md +145 -18
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. package/src/ui/toast.js +2 -0
package/src/ui/menu.js CHANGED
@@ -14,7 +14,7 @@
14
14
  * @module ui/menu
15
15
  */
16
16
 
17
- import { focusedElement, rootDocument } from '../dom/focus.js';
17
+ import { focusedElement, focusedIn, rootDocument } from '../dom/focus.js';
18
18
 
19
19
  /** The gap kept between a menu and the window's edges, in px. */
20
20
  export const MENU_EDGE = 8;
@@ -67,19 +67,21 @@ const BELOW_GAP = 4;
67
67
  *
68
68
  * @param {{width: number, height: number}} size - The menu's size.
69
69
  * @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
70
- * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
71
- * box, or a point; `placement` says where it goes from an anchor (default `'over'`).
70
+ * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
71
+ * anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
72
+ * (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
73
+ * point, as Chrome mirrors its own.
72
74
  * @returns {{left: number, top: number}} The menu's top-left, in px.
73
75
  * @example
74
76
  * menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
75
77
  * // → {left: anchor.right - 160, top: anchor.top}
76
78
  */
77
- export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over' }) {
79
+ export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over', rtl = false }) {
78
80
  const { width, height } = size;
79
81
  const left = (/** @type {number} */ start) => Math.max(MENU_EDGE, Math.min(start, viewport.width - width - MENU_EDGE));
80
82
  const room = (/** @type {number} */ top) => top + height <= viewport.height - MENU_EDGE;
81
- if (!anchor) return { left: left(x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
82
- const start = left(anchor.right - width);
83
+ if (!anchor) return { left: left(rtl ? x - width : x), top: Math.max(MENU_EDGE, room(y) ? y : y - height) };
84
+ const start = left(rtl ? anchor.left : anchor.right - width);
83
85
  if (placement === 'below') {
84
86
  const below = anchor.bottom + BELOW_GAP;
85
87
  return { left: start, top: Math.max(MENU_EDGE, room(below) ? below : anchor.top - height - BELOW_GAP) };
@@ -118,6 +120,8 @@ export function isInside(box, x, y) {
118
120
  * @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
119
121
  * and open it (closing it first if it's open).
120
122
  * @property {() => void} close Close it, if open, and return the focus.
123
+ * @property {() => void} destroy Take it off the page and stop listening to the window (a content
124
+ * script's overlay being removed).
121
125
  */
122
126
 
123
127
  /**
@@ -168,21 +172,32 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
168
172
 
169
173
  menu.addEventListener('keydown', (event) => {
170
174
  const list = items();
171
- const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedElement(doc)));
175
+ // Asked of the menu's own root: a closed shadow root's focused element can't be seen from the document.
176
+ const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedIn(menu)));
172
177
  /** @param {number} index */
173
178
  const go = (index) => {
174
- event.preventDefault();
179
+ handled();
175
180
  list[(index + list.length) % list.length]?.focus();
176
181
  };
182
+ /** The key is the menu's: the page under a content script's menu doesn't act on it too (its own shortcuts). */
183
+ const handled = () => {
184
+ event.preventDefault();
185
+ event.stopPropagation();
186
+ };
177
187
  if (event.key === 'ArrowDown') go(at + 1);
178
188
  else if (event.key === 'ArrowUp') go(at < 0 ? -1 : at - 1);
179
189
  else if (event.key === 'Home') go(0);
180
190
  else if (event.key === 'End') go(-1);
181
191
  else if (event.key === 'Escape' || event.key === 'Tab') {
182
- event.preventDefault();
192
+ handled();
183
193
  close();
184
194
  }
185
195
  });
196
+ // Closed any other way (the browser's own close, another script): the button no longer says it's open.
197
+ menu.addEventListener('close', () => {
198
+ expanded?.setAttribute('aria-expanded', 'false');
199
+ expanded = null;
200
+ });
186
201
  // Esc the browser handles itself (when the keydown above didn't see it).
187
202
  menu.addEventListener('cancel', (event) => {
188
203
  event.preventDefault();
@@ -219,7 +234,7 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
219
234
  if (label) menu.setAttribute('aria-label', label);
220
235
  else menu.removeAttribute('aria-label');
221
236
 
222
- const active = focusedElement(doc);
237
+ const active = focusedIn(menu) ?? focusedElement(doc);
223
238
  returnFocus = focusTo ?? anchor ?? (active instanceof view.HTMLElement ? active : null);
224
239
  if (anchor?.hasAttribute('aria-haspopup')) {
225
240
  anchor.setAttribute('aria-expanded', 'true');
@@ -228,13 +243,18 @@ export function createMenu({ id, placement = 'over', root, doc = rootDocument(ro
228
243
  menu.showModal();
229
244
  const { width, height } = menu.getBoundingClientRect();
230
245
  const { left, top } = menuPosition({ width, height }, { width: view.innerWidth, height: view.innerHeight }, {
231
- anchor: anchor?.getBoundingClientRect(), x, y, placement,
246
+ anchor: anchor?.getBoundingClientRect(), x, y, placement, rtl: view.getComputedStyle(menu).direction === 'rtl',
232
247
  });
233
248
  menu.style.left = `${left}px`;
234
249
  menu.style.top = `${top}px`;
235
250
  items()[0]?.focus();
236
251
  },
237
252
  close,
253
+ destroy() {
254
+ shut();
255
+ view.removeEventListener('blur', close);
256
+ menu.remove();
257
+ },
238
258
  };
239
259
  }
240
260
 
@@ -94,7 +94,11 @@ export function createModelRow({ status, help, download, progress }, { options,
94
94
  progress.value = loaded;
95
95
  status.textContent = t(MODEL_MESSAGES.downloading, [String(Math.round(loaded * 100))]);
96
96
  },
97
- }).then(async () => show(await state()), () => show('downloadable', t(MODEL_MESSAGES.downloadFailed)));
97
+ }).then(
98
+ // The state as the code that uses it sees it; if it can't be asked (the worker didn't answer), as this page does.
99
+ async (here) => show(await state().catch(() => here)),
100
+ () => show('downloadable', t(MODEL_MESSAGES.downloadFailed)),
101
+ );
98
102
  });
99
103
 
100
104
  return {
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A notice in a side panel's list (chrome-ui.css `.sp-notice`): a card with an icon, a heading,
3
+ * a line, buttons beside the heading and actions under the line. For what a list leaves out or
4
+ * can't do: frames from other sites to allow, a web font that didn't load.
5
+ *
6
+ * @module ui/notice
7
+ */
8
+ export type Notice = {
9
+ /**
10
+ * Its sprite symbol's id.
11
+ */
12
+ icon: string;
13
+ /**
14
+ * An information's icon is grey, a warning's red. Default `warning`.
15
+ */
16
+ tone?: 'info' | 'warning';
17
+ /**
18
+ * The heading.
19
+ */
20
+ title: string;
21
+ /**
22
+ * The line under it.
23
+ */
24
+ text: string;
25
+ /**
26
+ * Icon buttons at the heading's end.
27
+ */
28
+ buttons?: HTMLElement[];
29
+ /**
30
+ * Buttons under the line, at the end (“Allow”).
31
+ */
32
+ actions?: HTMLElement[];
33
+ /**
34
+ * The page. Default `document`.
35
+ */
36
+ doc?: Document;
37
+ };
38
+ /**
39
+ * A notice's parts.
40
+ *
41
+ * @typedef {object} Notice
42
+ * @property {string} icon Its sprite symbol's id.
43
+ * @property {'info' | 'warning'} [tone] An information's icon is grey, a warning's red. Default `warning`.
44
+ * @property {string} title The heading.
45
+ * @property {string} text The line under it.
46
+ * @property {HTMLElement[]} [buttons] Icon buttons at the heading's end.
47
+ * @property {HTMLElement[]} [actions] Buttons under the line, at the end (“Allow”).
48
+ * @property {Document} [doc] The page. Default `document`.
49
+ */
50
+ /**
51
+ * A notice card.
52
+ *
53
+ * @param {Notice} notice - Its parts.
54
+ * @returns {HTMLElement} The card.
55
+ * @example
56
+ * notices.append(noticeCard({ icon: 'i-frame', tone: 'info', title: t('panelFrames'), text, actions: [allow] }));
57
+ */
58
+ export declare function noticeCard({ icon, tone, title, text, buttons, actions, doc }: Notice): HTMLElement;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A notice in a side panel's list (chrome-ui.css `.sp-notice`): a card with an icon, a heading,
3
+ * a line, buttons beside the heading and actions under the line. For what a list leaves out or
4
+ * can't do: frames from other sites to allow, a web font that didn't load.
5
+ *
6
+ * @module ui/notice
7
+ */
8
+
9
+ import { make, spriteIcon } from '../dom/make.js';
10
+
11
+ /**
12
+ * A notice's parts.
13
+ *
14
+ * @typedef {object} Notice
15
+ * @property {string} icon Its sprite symbol's id.
16
+ * @property {'info' | 'warning'} [tone] An information's icon is grey, a warning's red. Default `warning`.
17
+ * @property {string} title The heading.
18
+ * @property {string} text The line under it.
19
+ * @property {HTMLElement[]} [buttons] Icon buttons at the heading's end.
20
+ * @property {HTMLElement[]} [actions] Buttons under the line, at the end (“Allow”).
21
+ * @property {Document} [doc] The page. Default `document`.
22
+ */
23
+
24
+ /**
25
+ * A notice card.
26
+ *
27
+ * @param {Notice} notice - Its parts.
28
+ * @returns {HTMLElement} The card.
29
+ * @example
30
+ * notices.append(noticeCard({ icon: 'i-frame', tone: 'info', title: t('panelFrames'), text, actions: [allow] }));
31
+ */
32
+ export function noticeCard({ icon, tone = 'warning', title, text, buttons = [], actions = [], doc = document }) {
33
+ const card = make('section', `sp-card sp-notice ${tone}`, '', doc);
34
+ const heading = make('div', 'sp-heading', '', doc);
35
+ heading.append(spriteIcon(icon, '', doc), make('h2', '', title, doc), ...buttons);
36
+ card.append(heading, make('p', 'sp-notice-text', text, doc));
37
+ if (actions.length) {
38
+ const row = make('div', 'sp-notice-actions', '', doc);
39
+ row.append(...actions);
40
+ card.append(row);
41
+ }
42
+ return card;
43
+ }
@@ -27,6 +27,7 @@
27
27
  * @module ui/site-list
28
28
  */
29
29
 
30
+ import { focusedIn } from '../dom/focus.js';
30
31
  import { createMenu, moreActionsButton } from './menu.js';
31
32
  import { askText } from './text-dialog.js';
32
33
  import { MAX_HOST_LENGTH, siteLabel } from '../sites/sites.js';
@@ -177,9 +178,11 @@ export function createSiteList({
177
178
  form?.addEventListener('submit', async (event) => {
178
179
  event.preventDefault();
179
180
  const input = /** @type {HTMLInputElement} */ (form.querySelector('input'));
180
- const site = parse(input.value);
181
- if (!site) invalid?.(input.value);
182
- else if (await add(site) !== false) input.value = '';
181
+ const typed = input.value;
182
+ const site = parse(typed);
183
+ if (!site) invalid?.(typed);
184
+ // Cleared only if it's still what was added: adding can wait on Chrome (a permission prompt), and the next site may be typed meanwhile.
185
+ else if (await add(site) !== false && input.value === typed) input.value = '';
183
186
  });
184
187
  addButton?.addEventListener('click', async () => {
185
188
  const site = await ask(w.addSite, w.add);
@@ -219,6 +222,11 @@ export function createSiteList({
219
222
 
220
223
  return {
221
224
  render(sites) {
225
+ // The focus stays in the list across a redraw: on the same row's button (the one after a removed
226
+ // row, the last after the last), or on the way to add when no row is left. Without this, the
227
+ // row's button a dialog or menu gave the focus back to is replaced and the focus falls to the page.
228
+ const focused = focusedIn(list);
229
+ const at = focused && list.contains(focused) ? [...list.children].findIndex((row) => row.contains(focused)) : -1;
222
230
  list.replaceChildren(...sites.map((site) => {
223
231
  const row = doc.createElement('li');
224
232
  row.className = 'row';
@@ -229,6 +237,7 @@ export function createSiteList({
229
237
  return row;
230
238
  }));
231
239
  if (empty) empty.hidden = sites.length > 0;
240
+ if (at >= 0) (list.children[Math.min(at, sites.length - 1)]?.querySelector('button') ?? addButton ?? form?.querySelector('input'))?.focus();
232
241
  },
233
242
  };
234
243
  }
@@ -4,8 +4,9 @@
4
4
  * folder's name (`askText`); several, as chrome://settings/searchEngines asks
5
5
  * for a site search (`askFields`). The action button stays disabled while a
6
6
  * field is blank or isn't valid, with the reason under that field. Enter
7
- * means the action button, Esc and Cancel cancel, and the focus goes back to
8
- * what opened it. The page needs no markup: the dialog (`#text-dialog`) is
7
+ * means the action button, Esc and Cancel cancel (as does the dialog being
8
+ * closed any other way), and the focus goes back to what opened it. A reason
9
+ * is announced as it appears, and its field marked invalid. The page needs no markup: the dialog (`#text-dialog`) is
9
10
  * made the first time, its fields each time it's asked.
10
11
  *
11
12
  * @module ui/text-dialog
@@ -4,8 +4,9 @@
4
4
  * folder's name (`askText`); several, as chrome://settings/searchEngines asks
5
5
  * for a site search (`askFields`). The action button stays disabled while a
6
6
  * field is blank or isn't valid, with the reason under that field. Enter
7
- * means the action button, Esc and Cancel cancel, and the focus goes back to
8
- * what opened it. The page needs no markup: the dialog (`#text-dialog`) is
7
+ * means the action button, Esc and Cancel cancel (as does the dialog being
8
+ * closed any other way), and the focus goes back to what opened it. A reason
9
+ * is announced as it appears, and its field marked invalid. The page needs no markup: the dialog (`#text-dialog`) is
9
10
  * made the first time, its fields each time it's asked.
10
11
  *
11
12
  * @module ui/text-dialog
@@ -178,7 +179,7 @@ function fieldFor(doc, request, only) {
178
179
  input.value = typeof request.value === 'string' ? request.value : '';
179
180
  if (request.placeholder) input.placeholder = request.placeholder;
180
181
  if (request.maxLength !== undefined) input.maxLength = request.maxLength;
181
- const error = element(doc, 'span', { id: ids.error, class: 'hint error' });
182
+ const error = element(doc, 'span', { id: ids.error, class: 'hint error', 'aria-live': 'polite' });
182
183
  error.hidden = true;
183
184
  const hint = element(doc, 'span', { id: ids.hint, class: 'hint secondary' });
184
185
  hint.hidden = true;
@@ -226,6 +227,9 @@ export function askFields({
226
227
  const { reason } = waiting ? { reason: '' } : checkText(input.value, request.validate);
227
228
  error.textContent = reason;
228
229
  error.hidden = !reason;
230
+ // Said to a screen reader as it appears, and the field marked (the stylesheet turns its underline red).
231
+ if (reason) input.setAttribute('aria-invalid', 'true');
232
+ else input.removeAttribute('aria-invalid');
229
233
  hint.textContent = waiting ? request.waiting ?? '' : typeof request.hint === 'function' ? request.hint(input.value) : request.hint ?? '';
230
234
  hint.hidden = !hint.textContent;
231
235
  };
@@ -291,6 +295,9 @@ export function askFields({
291
295
  event.preventDefault();
292
296
  settle(null);
293
297
  }, { signal });
298
+ // Closed any other way (the browser's own close after a second Esc, another script): cancelled, not left unanswered.
299
+ // (The event comes a task after the closing: one from the ask before this, already answered, finds the dialog open again.)
300
+ parts.dialog.addEventListener('close', () => { if (!parts.dialog.open) settle(null); }, { signal });
294
301
 
295
302
  parts.dialog.showModal();
296
303
  // The first field still to fill in, or the first, its text selected to type over.
package/src/ui/toast.js CHANGED
@@ -93,6 +93,8 @@ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDurat
93
93
  text.textContent = message;
94
94
  undoButton.hidden = !undo;
95
95
  undoLast = undo;
96
+ // Taken off the page since (a single-page site replaced its body): put back, or showing it would throw.
97
+ if (!toast.isConnected) (root?.isConnected === false ? doc.body : root ?? doc.body).append(toast);
96
98
  // Hidden and shown again, so it's the newest in the top layer (over a popover since).
97
99
  toast.togglePopover(false);
98
100
  toast.togglePopover(true);