@arraypress/extension-kit 1.2.0 → 1.4.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 (43) hide show
  1. package/README.md +147 -17
  2. package/bin/extension-kit.js +46 -10
  3. package/package.json +1 -1
  4. package/src/build/index.d.ts +4 -2
  5. package/src/build/index.js +6 -2
  6. package/src/build/store-frame.d.ts +176 -0
  7. package/src/build/store-frame.js +241 -0
  8. package/src/build/test-build.d.ts +85 -0
  9. package/src/build/test-build.js +82 -0
  10. package/src/build/toolbar-icons.d.ts +36 -18
  11. package/src/build/toolbar-icons.js +91 -24
  12. package/src/build/vendor.d.ts +78 -8
  13. package/src/build/vendor.js +133 -10
  14. package/src/build/zip.d.ts +3 -2
  15. package/src/build/zip.js +6 -4
  16. package/src/dom/focus.d.ts +28 -0
  17. package/src/dom/focus.js +36 -0
  18. package/src/dom/i18n.d.ts +11 -3
  19. package/src/dom/i18n.js +17 -4
  20. package/src/dom/setup.d.ts +55 -0
  21. package/src/dom/setup.js +47 -0
  22. package/src/index.d.ts +9 -1
  23. package/src/index.js +10 -1
  24. package/src/sites/sites.d.ts +114 -0
  25. package/src/sites/sites.js +169 -0
  26. package/src/styles/chrome-ui.css +13 -6
  27. package/src/testing/browser.d.ts +52 -0
  28. package/src/testing/browser.js +79 -0
  29. package/src/testing/e2e.d.ts +18 -5
  30. package/src/testing/e2e.js +17 -5
  31. package/src/testing/index.d.ts +7 -3
  32. package/src/testing/index.js +9 -3
  33. package/src/testing/pool.d.ts +23 -0
  34. package/src/testing/pool.js +33 -0
  35. package/src/time/time.d.ts +36 -0
  36. package/src/time/time.js +47 -0
  37. package/src/ui/menu.d.ts +13 -6
  38. package/src/ui/menu.js +11 -7
  39. package/src/ui/site-list.d.ts +216 -0
  40. package/src/ui/site-list.js +234 -0
  41. package/src/ui/text-dialog.js +3 -1
  42. package/src/ui/toast.d.ts +25 -9
  43. package/src/ui/toast.js +25 -11
@@ -0,0 +1,234 @@
1
+ /**
2
+ * A settings card's list of sites (“Never on”, “Allowed sites”), in one of
3
+ * Chrome's two ways:
4
+ *
5
+ * - **With an add row** (`form`), as a short list in a chrome://settings
6
+ * card: a row per site with a Remove button, and below them a field and an
7
+ * Add button.
8
+ * - **With an Add button and a dialog** (`addButton`), as
9
+ * chrome://settings/performance lists the sites Memory Saver keeps active:
10
+ * the button asks for a site in the one-field dialog (“Add site”), and each
11
+ * row has a ⋮ menu with Edit and Remove.
12
+ *
13
+ * Either way, an optional “No sites added” row shows while there are none.
14
+ * The page's markup holds the list, the empty row and the form or button (so
15
+ * they're localised with the rest of the page); this fills the list and wires
16
+ * them up.
17
+ *
18
+ * ```html
19
+ * <ul class="list" id="never-sites"></ul>
20
+ * <p class="row text" id="no-sites"><span class="label secondary">No sites added</span></p>
21
+ * <form class="row add" id="add-never">
22
+ * <input type="text" placeholder="example.com" aria-label="Site" autocomplete="off" spellcheck="false">
23
+ * <button type="submit">Add</button>
24
+ * </form>
25
+ * ```
26
+ *
27
+ * @module ui/site-list
28
+ */
29
+
30
+ import { createMenu, moreActionsButton } from './menu.js';
31
+ import { askText } from './text-dialog.js';
32
+ import { MAX_HOST_LENGTH, siteLabel } from '../sites/sites.js';
33
+
34
+ /**
35
+ * The list's words, Chrome's English by default; a page passes its translations.
36
+ *
37
+ * @typedef {object} SiteListWords
38
+ * @property {string} add The Add button in the dialog.
39
+ * @property {string} addSite The dialog's title when adding.
40
+ * @property {string} editSite The dialog's title when editing.
41
+ * @property {string} edit The ⋮ menu's Edit item.
42
+ * @property {string} remove The Remove button, or the ⋮ menu's Remove item.
43
+ * @property {string} save The dialog's action when editing.
44
+ * @property {string} cancel The dialog's Cancel button.
45
+ * @property {string} site The dialog's field label.
46
+ * @property {string} invalid Under the dialog's field while what's typed isn't a site.
47
+ * @property {(label: string) => string} removeLabel The Remove button's name for screen readers.
48
+ * @property {(label: string) => string} moreActions The ⋮ button's name for screen readers.
49
+ */
50
+
51
+ /** @type {Readonly<SiteListWords>} */
52
+ export const SITE_LIST_WORDS = Object.freeze({
53
+ add: 'Add',
54
+ addSite: 'Add site',
55
+ editSite: 'Edit site',
56
+ edit: 'Edit',
57
+ remove: 'Remove',
58
+ save: 'Save',
59
+ cancel: 'Cancel',
60
+ site: 'Site',
61
+ invalid: 'Enter a site, like example.com',
62
+ removeLabel: (/** @type {string} */ label) => `Remove “${label}”`,
63
+ moreActions: (/** @type {string} */ label) => `More actions for “${label}”`,
64
+ });
65
+
66
+ /**
67
+ * The message key each word is read from when a page passes its `t`, so
68
+ * every extension's `_locales` names them alike. `removeLabel` and
69
+ * `moreActions` take the site as `$1` (`"More actions for $SITE$"`).
70
+ *
71
+ * @type {Readonly<Record<keyof SiteListWords, string>>}
72
+ */
73
+ export const SITE_LIST_MESSAGES = Object.freeze({
74
+ add: 'optionsAdd',
75
+ addSite: 'optionsAddSite',
76
+ editSite: 'optionsEditSite',
77
+ edit: 'optionsEdit',
78
+ remove: 'optionsRemove',
79
+ save: 'optionsSave',
80
+ cancel: 'optionsCancel',
81
+ site: 'optionsSiteLabel',
82
+ invalid: 'optionsSiteInvalid',
83
+ removeLabel: 'optionsRemoveLabel',
84
+ moreActions: 'optionsMoreActions',
85
+ });
86
+
87
+ /**
88
+ * The list's words from a page's messages, under {@link SITE_LIST_MESSAGES}.
89
+ *
90
+ * @param {(key: string, substitutions?: string | string[]) => string} t - The page's message lookup.
91
+ * @returns {SiteListWords} The words.
92
+ */
93
+ function translatedWords(t) {
94
+ const m = SITE_LIST_MESSAGES;
95
+ return {
96
+ add: t(m.add), addSite: t(m.addSite), editSite: t(m.editSite), edit: t(m.edit), remove: t(m.remove),
97
+ save: t(m.save), cancel: t(m.cancel), site: t(m.site), invalid: t(m.invalid),
98
+ removeLabel: (label) => t(m.removeLabel, [label]),
99
+ moreActions: (label) => t(m.moreActions, [label]),
100
+ };
101
+ }
102
+
103
+ /**
104
+ * What {@link createSiteList} takes: the list, one way to add to it, and
105
+ * what adding, removing and editing do.
106
+ *
107
+ * @typedef {object} SiteListOptions
108
+ * @property {HTMLElement} list The `<ul class="list">` to fill.
109
+ * @property {HTMLElement} [empty] Shown while the list is empty.
110
+ * @property {HTMLFormElement} [form] An add row: a form with one text field and a submit button.
111
+ * @property {HTMLElement} [addButton] Or an Add button that asks in the dialog; each row then has a
112
+ * ⋮ menu (Edit, Remove) instead of a Remove button.
113
+ * @property {(text: string) => string | null} parse What was typed as the site the list keeps
114
+ * (`siteHost`, `siteOrigin`), or null when it isn't one.
115
+ * @property {(site: string) => unknown} add Adds a parsed site (asking Chrome for access first, if
116
+ * the list needs it). Resolve to false to keep the text in the add row's field (access refused).
117
+ * @property {(site: string) => unknown} remove Removes a site.
118
+ * @property {(site: string, edited: string) => unknown} [replace] Puts an edited site in place of
119
+ * the old one (the ⋮ menu's Edit; without it, the menu has only Remove).
120
+ * @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
121
+ * @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
122
+ * @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
123
+ * @property {(key: string, substitutions?: string | string[]) => string} [t] The page's message lookup
124
+ * (`setUpPage`'s): the words are then its messages under {@link SITE_LIST_MESSAGES}. Without it,
125
+ * Chrome's English ({@link SITE_LIST_WORDS}).
126
+ * @property {Partial<SiteListWords>} [words] Words that differ from those, by name.
127
+ */
128
+
129
+ /**
130
+ * A site list.
131
+ *
132
+ * @typedef {object} SiteList
133
+ * @property {(sites: readonly string[]) => void} render Show these sites (call it whenever they change).
134
+ */
135
+
136
+ /**
137
+ * Fill a site list and wire its way of adding: an add row (typing a site and
138
+ * pressing Enter or Add) or an Add button (the dialog, which keeps its Add off
139
+ * until what's typed is a site). A row's Remove (or its ⋮ menu's Remove and
140
+ * Edit) hands its site to `remove` (or `replace`).
141
+ *
142
+ * @param {SiteListOptions} options - The markup, the parser and what adding and removing do.
143
+ * @returns {SiteList} The list.
144
+ * @example
145
+ * const never = createSiteList({
146
+ * list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
147
+ * parse: siteHost,
148
+ * add: (host) => save({ neverSites: withSite(settings.neverSites, host) }),
149
+ * remove: (host) => save({ neverSites: withoutSite(settings.neverSites, host) }),
150
+ * t, // its words from optionsAdd, optionsRemove, …
151
+ * });
152
+ * never.render(settings.neverSites);
153
+ */
154
+ export function createSiteList({
155
+ list, empty, form, addButton, parse, add, remove, replace, invalid, label = siteLabel, maxLength = MAX_HOST_LENGTH, t, words,
156
+ }) {
157
+ const doc = list.ownerDocument;
158
+ const w = { ...(t ? translatedWords(t) : SITE_LIST_WORDS), ...words };
159
+ const menu = addButton ? createMenu({ doc }) : null;
160
+
161
+ /**
162
+ * Ask for a site in the dialog.
163
+ *
164
+ * @param {string} title - Its title.
165
+ * @param {string} action - Its action button.
166
+ * @param {string} [value] - The site to start with.
167
+ * @returns {Promise<string | null>} The site, parsed, or null when cancelled.
168
+ */
169
+ const ask = async (title, action, value) => {
170
+ const text = await askText({
171
+ title, label: w.site, value, action, cancel: w.cancel, maxLength, doc,
172
+ validate: (typed) => (parse(typed) ? null : w.invalid),
173
+ });
174
+ return text === null ? null : parse(text);
175
+ };
176
+
177
+ form?.addEventListener('submit', async (event) => {
178
+ event.preventDefault();
179
+ 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 = '';
183
+ });
184
+ addButton?.addEventListener('click', async () => {
185
+ const site = await ask(w.addSite, w.add);
186
+ if (site) await add(site);
187
+ });
188
+
189
+ /**
190
+ * The end of a site's row: its ⋮ button, or its Remove button.
191
+ *
192
+ * @param {string} site - The site.
193
+ * @param {string} name - How it reads.
194
+ * @returns {HTMLButtonElement} The button.
195
+ */
196
+ const rowEnd = (site, name) => {
197
+ if (menu) {
198
+ return moreActionsButton(name, {
199
+ label: w.moreActions(name), doc,
200
+ onClick: (anchor) => menu.open([
201
+ ...replace ? [{
202
+ label: w.edit,
203
+ run: async () => {
204
+ const edited = await ask(w.editSite, w.save, name);
205
+ if (edited && edited !== site) await replace(site, edited);
206
+ },
207
+ }] : [],
208
+ { label: w.remove, run: () => remove(site) },
209
+ ], { anchor }),
210
+ });
211
+ }
212
+ const button = doc.createElement('button');
213
+ button.type = 'button';
214
+ button.textContent = w.remove;
215
+ button.setAttribute('aria-label', w.removeLabel(name));
216
+ button.addEventListener('click', () => remove(site));
217
+ return button;
218
+ };
219
+
220
+ return {
221
+ render(sites) {
222
+ list.replaceChildren(...sites.map((site) => {
223
+ const row = doc.createElement('li');
224
+ row.className = 'row';
225
+ const name = doc.createElement('span');
226
+ name.className = 'label';
227
+ name.textContent = label(site);
228
+ row.append(name, rowEnd(site, name.textContent));
229
+ return row;
230
+ }));
231
+ if (empty) empty.hidden = sites.length > 0;
232
+ },
233
+ };
234
+ }
@@ -9,6 +9,8 @@
9
9
  * @module ui/text-dialog
10
10
  */
11
11
 
12
+ import { focusedElement } from '../dom/focus.js';
13
+
12
14
  /**
13
15
  * What the dialog asks.
14
16
  *
@@ -126,7 +128,7 @@ export function askText({
126
128
  }) {
127
129
  const parts = partsFor(doc);
128
130
  pending.get(doc)?.(null);
129
- const active = doc.activeElement;
131
+ const active = focusedElement(doc);
130
132
  const back = returnTo === undefined ? (active instanceof /** @type {Window & typeof globalThis} */ (doc.defaultView).HTMLElement ? active : null) : returnTo;
131
133
 
132
134
  parts.title.textContent = title;
package/src/ui/toast.d.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  * after a delete: "Deleted “Research” Undo". One per page, made by
4
4
  * {@link createToast}; a new message replaces the last and restarts the wait.
5
5
  *
6
+ * It's a manual `popover`, so it shows in the top layer: over anything the
7
+ * page stacks (a content script's toast over a site's own fixed bars), and
8
+ * never clipped by an ancestor.
9
+ *
6
10
  * @module ui/toast
7
11
  */
8
12
  /** How long chrome://bookmarks shows a toast (its cr-toast-manager's `duration`), in ms. */
@@ -21,13 +25,19 @@ export type ToastOptions = {
21
25
  */
22
26
  undoDuration?: number;
23
27
  /**
24
- * The page. Default `document`.
28
+ * Where the toast goes: an element, or a shadow root (a content
29
+ * script's). Default the page's `body`.
30
+ */
31
+ root?: ParentNode;
32
+ /**
33
+ * The page. Default `root`'s document, else `document`.
25
34
  */
26
35
  doc?: Document;
27
36
  };
28
37
  export type Toast = {
29
38
  /**
30
- * The `#toast` element (`role="status"`), in the page's body.
39
+ * The `#toast` element (`role="status"`, `popover="manual"`), in
40
+ * `root`; `:popover-open` while it shows.
31
41
  */
32
42
  element: HTMLElement;
33
43
  /**
@@ -47,27 +57,33 @@ export type Toast = {
47
57
  * @property {string} [undoLabel] The undo button's text. Default "Undo".
48
58
  * @property {number} [duration] How long a toast stays, in ms. Default {@link TOAST_MS}.
49
59
  * @property {number} [undoDuration] How long one with Undo stays. Default `duration`.
50
- * @property {Document} [doc] The page. Default `document`.
60
+ * @property {ParentNode} [root] Where the toast goes: an element, or a shadow root (a content
61
+ * script's). Default the page's `body`.
62
+ * @property {Document} [doc] The page. Default `root`'s document, else `document`.
51
63
  */
52
64
  /**
53
65
  * A page's toast.
54
66
  *
55
67
  * @typedef {object} Toast
56
- * @property {HTMLElement} element The `#toast` element (`role="status"`), in the page's body.
68
+ * @property {HTMLElement} element The `#toast` element (`role="status"`, `popover="manual"`), in
69
+ * `root`; `:popover-open` while it shows.
57
70
  * @property {(text: string, undo?: (() => unknown) | null) => void} show Show `text`, with an Undo
58
71
  * button that hides the toast and runs `undo` when one is given.
59
72
  * @property {() => void} hide Take it down (and its Undo with it).
60
73
  */
61
74
  /**
62
- * Make the page's toast: `<div id="toast" class="toast" role="status">` with
63
- * `#toast-text` and an Undo button `#toast-undo`, hidden until shown. Make it
64
- * when the page loads, so screen readers know the live region before it speaks.
75
+ * Make the page's toast: `<div id="toast" class="toast" role="status"
76
+ * popover="manual">` with `#toast-text` and an Undo button `#toast-undo`,
77
+ * closed until shown. Make it when the page loads, so screen readers know the
78
+ * live region before it speaks.
65
79
  *
66
- * @param {ToastOptions} [options] - The Undo text, how long it stays, and the page.
80
+ * @param {ToastOptions} [options] - The Undo text, how long it stays, and where it goes.
67
81
  * @returns {Toast} The toast.
68
82
  * @example
69
83
  * const toast = createToast();
70
84
  * await deleteWorkspace(workspace);
71
85
  * toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
86
+ * // In a content script's shadow root:
87
+ * createToast({ root: host.attachShadow({ mode: 'open' }), duration: 4000 });
72
88
  */
73
- export declare function createToast({ undoLabel, duration, undoDuration, doc }?: ToastOptions): Toast;
89
+ export declare function createToast({ undoLabel, duration, undoDuration, root, doc }?: ToastOptions): Toast;
package/src/ui/toast.js CHANGED
@@ -3,9 +3,15 @@
3
3
  * after a delete: "Deleted “Research” Undo". One per page, made by
4
4
  * {@link createToast}; a new message replaces the last and restarts the wait.
5
5
  *
6
+ * It's a manual `popover`, so it shows in the top layer: over anything the
7
+ * page stacks (a content script's toast over a site's own fixed bars), and
8
+ * never clipped by an ancestor.
9
+ *
6
10
  * @module ui/toast
7
11
  */
8
12
 
13
+ import { rootDocument } from '../dom/focus.js';
14
+
9
15
  /** How long chrome://bookmarks shows a toast (its cr-toast-manager's `duration`), in ms. */
10
16
  export const TOAST_MS = 10_000;
11
17
 
@@ -16,38 +22,44 @@ export const TOAST_MS = 10_000;
16
22
  * @property {string} [undoLabel] The undo button's text. Default "Undo".
17
23
  * @property {number} [duration] How long a toast stays, in ms. Default {@link TOAST_MS}.
18
24
  * @property {number} [undoDuration] How long one with Undo stays. Default `duration`.
19
- * @property {Document} [doc] The page. Default `document`.
25
+ * @property {ParentNode} [root] Where the toast goes: an element, or a shadow root (a content
26
+ * script's). Default the page's `body`.
27
+ * @property {Document} [doc] The page. Default `root`'s document, else `document`.
20
28
  */
21
29
 
22
30
  /**
23
31
  * A page's toast.
24
32
  *
25
33
  * @typedef {object} Toast
26
- * @property {HTMLElement} element The `#toast` element (`role="status"`), in the page's body.
34
+ * @property {HTMLElement} element The `#toast` element (`role="status"`, `popover="manual"`), in
35
+ * `root`; `:popover-open` while it shows.
27
36
  * @property {(text: string, undo?: (() => unknown) | null) => void} show Show `text`, with an Undo
28
37
  * button that hides the toast and runs `undo` when one is given.
29
38
  * @property {() => void} hide Take it down (and its Undo with it).
30
39
  */
31
40
 
32
41
  /**
33
- * Make the page's toast: `<div id="toast" class="toast" role="status">` with
34
- * `#toast-text` and an Undo button `#toast-undo`, hidden until shown. Make it
35
- * when the page loads, so screen readers know the live region before it speaks.
42
+ * Make the page's toast: `<div id="toast" class="toast" role="status"
43
+ * popover="manual">` with `#toast-text` and an Undo button `#toast-undo`,
44
+ * closed until shown. Make it when the page loads, so screen readers know the
45
+ * live region before it speaks.
36
46
  *
37
- * @param {ToastOptions} [options] - The Undo text, how long it stays, and the page.
47
+ * @param {ToastOptions} [options] - The Undo text, how long it stays, and where it goes.
38
48
  * @returns {Toast} The toast.
39
49
  * @example
40
50
  * const toast = createToast();
41
51
  * await deleteWorkspace(workspace);
42
52
  * toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
53
+ * // In a content script's shadow root:
54
+ * createToast({ root: host.attachShadow({ mode: 'open' }), duration: 4000 });
43
55
  */
44
- export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDuration = duration, doc = document } = {}) {
56
+ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDuration = duration, root, doc = rootDocument(root) } = {}) {
45
57
  const toast = doc.createElement('div');
46
58
  toast.id = 'toast';
47
59
  toast.className = 'toast';
48
60
  toast.setAttribute('role', 'status');
49
61
  toast.setAttribute('aria-live', 'polite');
50
- toast.hidden = true;
62
+ toast.popover = 'manual';
51
63
  const text = doc.createElement('span');
52
64
  text.id = 'toast-text';
53
65
  const undoButton = doc.createElement('button');
@@ -56,7 +68,7 @@ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDurat
56
68
  undoButton.textContent = undoLabel;
57
69
  undoButton.hidden = true;
58
70
  toast.append(text, undoButton);
59
- doc.body.append(toast);
71
+ (root ?? doc.body).append(toast);
60
72
 
61
73
  /** @type {ReturnType<typeof setTimeout> | undefined} */
62
74
  let timer;
@@ -65,7 +77,7 @@ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDurat
65
77
 
66
78
  const hide = () => {
67
79
  clearTimeout(timer);
68
- toast.hidden = true;
80
+ toast.togglePopover(false);
69
81
  undoLast = null;
70
82
  };
71
83
  undoButton.addEventListener('click', () => {
@@ -81,7 +93,9 @@ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDurat
81
93
  text.textContent = message;
82
94
  undoButton.hidden = !undo;
83
95
  undoLast = undo;
84
- toast.hidden = false;
96
+ // Hidden and shown again, so it's the newest in the top layer (over a popover since).
97
+ toast.togglePopover(false);
98
+ toast.togglePopover(true);
85
99
  timer = setTimeout(hide, undo ? undoDuration : duration);
86
100
  },
87
101
  hide,