@arraypress/extension-kit 1.2.0 → 1.3.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.
- package/README.md +100 -17
- package/bin/extension-kit.js +46 -10
- package/package.json +1 -1
- package/src/build/index.d.ts +4 -2
- package/src/build/index.js +6 -2
- package/src/build/store-frame.d.ts +176 -0
- package/src/build/store-frame.js +241 -0
- package/src/build/test-build.d.ts +85 -0
- package/src/build/test-build.js +82 -0
- package/src/build/toolbar-icons.d.ts +36 -18
- package/src/build/toolbar-icons.js +91 -24
- package/src/build/vendor.d.ts +78 -8
- package/src/build/vendor.js +133 -10
- package/src/build/zip.d.ts +3 -2
- package/src/build/zip.js +6 -4
- package/src/dom/focus.d.ts +28 -0
- package/src/dom/focus.js +36 -0
- package/src/dom/setup.d.ts +54 -0
- package/src/dom/setup.js +46 -0
- package/src/index.d.ts +8 -1
- package/src/index.js +7 -1
- package/src/sites/sites.d.ts +82 -0
- package/src/sites/sites.js +128 -0
- package/src/styles/chrome-ui.css +13 -6
- package/src/testing/browser.d.ts +52 -0
- package/src/testing/browser.js +79 -0
- package/src/testing/e2e.d.ts +18 -5
- package/src/testing/e2e.js +17 -5
- package/src/testing/index.d.ts +8 -3
- package/src/testing/index.js +10 -3
- package/src/testing/pool.d.ts +23 -0
- package/src/testing/pool.js +33 -0
- package/src/testing/time.d.ts +35 -0
- package/src/testing/time.js +46 -0
- package/src/ui/menu.d.ts +13 -6
- package/src/ui/menu.js +11 -7
- package/src/ui/site-list.d.ts +199 -0
- package/src/ui/site-list.js +194 -0
- package/src/ui/text-dialog.js +3 -1
- package/src/ui/toast.d.ts +25 -9
- package/src/ui/toast.js +25 -11
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time limits for tests that drive a real browser, where a page that stops
|
|
3
|
+
* responding would otherwise hold a run up forever.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/time
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Settle `promise` within `ms`, or settle the way `late` says. The timer is
|
|
10
|
+
* cleared as soon as either happens, so a finished run isn't kept waiting
|
|
11
|
+
* for it. The work itself isn't stopped.
|
|
12
|
+
*
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {Promise<T>} promise - The work.
|
|
15
|
+
* @param {number} ms - The limit.
|
|
16
|
+
* @param {(resolve: (value: T) => void, reject: (error: Error) => void) => void} late - Runs when the
|
|
17
|
+
* limit passes first.
|
|
18
|
+
* @returns {Promise<T>} The work's outcome, or `late`'s.
|
|
19
|
+
*/
|
|
20
|
+
export function deadline(promise, ms, late) {
|
|
21
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
22
|
+
let timer;
|
|
23
|
+
const limit = new Promise((resolve, reject) => {
|
|
24
|
+
timer = setTimeout(() => late(resolve, reject), ms);
|
|
25
|
+
});
|
|
26
|
+
return /** @type {Promise<T>} */ (Promise.race([promise, limit])).finally(() => clearTimeout(timer));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A promise's value within a time limit, or `fallback` when it's late or
|
|
31
|
+
* fails: for a step a live run should give up on and go past (a renderer
|
|
32
|
+
* that stopped responding, a close that never returns). The work itself
|
|
33
|
+
* isn't stopped; the timer is cleared once it settles.
|
|
34
|
+
*
|
|
35
|
+
* @template T
|
|
36
|
+
* @param {Promise<T>} promise - The work.
|
|
37
|
+
* @param {number} ms - The limit.
|
|
38
|
+
* @param {T} fallback - What to give if it's late or fails.
|
|
39
|
+
* @returns {Promise<T>} Its value, or the fallback. Never rejects.
|
|
40
|
+
* @example
|
|
41
|
+
* const list = await within(handled(worker, page), 8000, []);
|
|
42
|
+
* await within(page.close(), 15000, undefined);
|
|
43
|
+
*/
|
|
44
|
+
export function within(promise, ms, fallback) {
|
|
45
|
+
return deadline(promise.catch(() => fallback), ms, (resolve) => resolve(fallback));
|
|
46
|
+
}
|
package/src/ui/menu.d.ts
CHANGED
|
@@ -140,13 +140,18 @@ export type MenuOptions = {
|
|
|
140
140
|
*/
|
|
141
141
|
placement?: 'over' | 'below';
|
|
142
142
|
/**
|
|
143
|
-
*
|
|
143
|
+
* Where the menu goes: an element, or a shadow root (a content
|
|
144
|
+
* script's, so the page's styles never reach it). Default the page's `body`.
|
|
145
|
+
*/
|
|
146
|
+
root?: ParentNode;
|
|
147
|
+
/**
|
|
148
|
+
* The page. Default `root`'s document, else `document`.
|
|
144
149
|
*/
|
|
145
150
|
doc?: Document;
|
|
146
151
|
};
|
|
147
152
|
export type ActionMenu = {
|
|
148
153
|
/**
|
|
149
|
-
* The `<dialog class="menu" role="menu">`, in
|
|
154
|
+
* The `<dialog class="menu" role="menu">`, in `root`.
|
|
150
155
|
*/
|
|
151
156
|
element: HTMLDialogElement;
|
|
152
157
|
/**
|
|
@@ -165,13 +170,15 @@ export type ActionMenu = {
|
|
|
165
170
|
* @typedef {object} MenuOptions
|
|
166
171
|
* @property {string} [id] The menu element's id.
|
|
167
172
|
* @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
|
|
168
|
-
* @property {
|
|
173
|
+
* @property {ParentNode} [root] Where the menu goes: an element, or a shadow root (a content
|
|
174
|
+
* script's, so the page's styles never reach it). Default the page's `body`.
|
|
175
|
+
* @property {Document} [doc] The page. Default `root`'s document, else `document`.
|
|
169
176
|
*/
|
|
170
177
|
/**
|
|
171
178
|
* A page's action menu.
|
|
172
179
|
*
|
|
173
180
|
* @typedef {object} ActionMenu
|
|
174
|
-
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in
|
|
181
|
+
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in `root`.
|
|
175
182
|
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
176
183
|
* and open it (closing it first if it's open).
|
|
177
184
|
* @property {() => void} close Close it, if open, and return the focus.
|
|
@@ -180,7 +187,7 @@ export type ActionMenu = {
|
|
|
180
187
|
* Make a page's action menu: one `<dialog class="menu">` that any number of
|
|
181
188
|
* ⋮ buttons (or right-clicks) open with their own items.
|
|
182
189
|
*
|
|
183
|
-
* @param {MenuOptions} [options] - Its id, placement and
|
|
190
|
+
* @param {MenuOptions} [options] - Its id, placement, and where it goes.
|
|
184
191
|
* @returns {ActionMenu} The menu.
|
|
185
192
|
* @example
|
|
186
193
|
* const menu = createMenu();
|
|
@@ -192,7 +199,7 @@ export type ActionMenu = {
|
|
|
192
199
|
* ], {anchor: button}),
|
|
193
200
|
* }));
|
|
194
201
|
*/
|
|
195
|
-
export declare function createMenu({ id, placement, doc }?: MenuOptions): ActionMenu;
|
|
202
|
+
export declare function createMenu({ id, placement, root, doc }?: MenuOptions): ActionMenu;
|
|
196
203
|
export type MoreActionsOptions = {
|
|
197
204
|
/**
|
|
198
205
|
* The whole label, for a page that translates it. Default
|
package/src/ui/menu.js
CHANGED
|
@@ -14,6 +14,8 @@
|
|
|
14
14
|
* @module ui/menu
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
+
import { focusedElement, rootDocument } from '../dom/focus.js';
|
|
18
|
+
|
|
17
19
|
/** The gap kept between a menu and the window's edges, in px. */
|
|
18
20
|
export const MENU_EDGE = 8;
|
|
19
21
|
|
|
@@ -103,14 +105,16 @@ export function isInside(box, x, y) {
|
|
|
103
105
|
* @typedef {object} MenuOptions
|
|
104
106
|
* @property {string} [id] The menu element's id.
|
|
105
107
|
* @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
|
|
106
|
-
* @property {
|
|
108
|
+
* @property {ParentNode} [root] Where the menu goes: an element, or a shadow root (a content
|
|
109
|
+
* script's, so the page's styles never reach it). Default the page's `body`.
|
|
110
|
+
* @property {Document} [doc] The page. Default `root`'s document, else `document`.
|
|
107
111
|
*/
|
|
108
112
|
|
|
109
113
|
/**
|
|
110
114
|
* A page's action menu.
|
|
111
115
|
*
|
|
112
116
|
* @typedef {object} ActionMenu
|
|
113
|
-
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in
|
|
117
|
+
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in `root`.
|
|
114
118
|
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
115
119
|
* and open it (closing it first if it's open).
|
|
116
120
|
* @property {() => void} close Close it, if open, and return the focus.
|
|
@@ -120,7 +124,7 @@ export function isInside(box, x, y) {
|
|
|
120
124
|
* Make a page's action menu: one `<dialog class="menu">` that any number of
|
|
121
125
|
* ⋮ buttons (or right-clicks) open with their own items.
|
|
122
126
|
*
|
|
123
|
-
* @param {MenuOptions} [options] - Its id, placement and
|
|
127
|
+
* @param {MenuOptions} [options] - Its id, placement, and where it goes.
|
|
124
128
|
* @returns {ActionMenu} The menu.
|
|
125
129
|
* @example
|
|
126
130
|
* const menu = createMenu();
|
|
@@ -132,13 +136,13 @@ export function isInside(box, x, y) {
|
|
|
132
136
|
* ], {anchor: button}),
|
|
133
137
|
* }));
|
|
134
138
|
*/
|
|
135
|
-
export function createMenu({ id, placement = 'over', doc =
|
|
139
|
+
export function createMenu({ id, placement = 'over', root, doc = rootDocument(root) } = {}) {
|
|
136
140
|
const view = /** @type {Window & typeof globalThis} */ (doc.defaultView);
|
|
137
141
|
const menu = doc.createElement('dialog');
|
|
138
142
|
menu.className = 'menu';
|
|
139
143
|
menu.setAttribute('role', 'menu');
|
|
140
144
|
if (id) menu.id = id;
|
|
141
|
-
doc.body.append(menu);
|
|
145
|
+
(root ?? doc.body).append(menu);
|
|
142
146
|
|
|
143
147
|
/** @type {HTMLElement | null} */
|
|
144
148
|
let returnFocus = null;
|
|
@@ -164,7 +168,7 @@ export function createMenu({ id, placement = 'over', doc = document } = {}) {
|
|
|
164
168
|
|
|
165
169
|
menu.addEventListener('keydown', (event) => {
|
|
166
170
|
const list = items();
|
|
167
|
-
const at = list.indexOf(/** @type {HTMLButtonElement} */ (doc
|
|
171
|
+
const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedElement(doc)));
|
|
168
172
|
/** @param {number} index */
|
|
169
173
|
const go = (index) => {
|
|
170
174
|
event.preventDefault();
|
|
@@ -215,7 +219,7 @@ export function createMenu({ id, placement = 'over', doc = document } = {}) {
|
|
|
215
219
|
if (label) menu.setAttribute('aria-label', label);
|
|
216
220
|
else menu.removeAttribute('aria-label');
|
|
217
221
|
|
|
218
|
-
const active = doc
|
|
222
|
+
const active = focusedElement(doc);
|
|
219
223
|
returnFocus = focusTo ?? anchor ?? (active instanceof view.HTMLElement ? active : null);
|
|
220
224
|
if (anchor?.hasAttribute('aria-haspopup')) {
|
|
221
225
|
anchor.setAttribute('aria-expanded', 'true');
|
|
@@ -0,0 +1,199 @@
|
|
|
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
|
+
export type SiteListWords = {
|
|
30
|
+
/**
|
|
31
|
+
* The Add button in the dialog.
|
|
32
|
+
*/
|
|
33
|
+
add: string;
|
|
34
|
+
/**
|
|
35
|
+
* The dialog's title when adding.
|
|
36
|
+
*/
|
|
37
|
+
addSite: string;
|
|
38
|
+
/**
|
|
39
|
+
* The dialog's title when editing.
|
|
40
|
+
*/
|
|
41
|
+
editSite: string;
|
|
42
|
+
/**
|
|
43
|
+
* The ⋮ menu's Edit item.
|
|
44
|
+
*/
|
|
45
|
+
edit: string;
|
|
46
|
+
/**
|
|
47
|
+
* The Remove button, or the ⋮ menu's Remove item.
|
|
48
|
+
*/
|
|
49
|
+
remove: string;
|
|
50
|
+
/**
|
|
51
|
+
* The dialog's action when editing.
|
|
52
|
+
*/
|
|
53
|
+
save: string;
|
|
54
|
+
/**
|
|
55
|
+
* The dialog's Cancel button.
|
|
56
|
+
*/
|
|
57
|
+
cancel: string;
|
|
58
|
+
/**
|
|
59
|
+
* The dialog's field label.
|
|
60
|
+
*/
|
|
61
|
+
site: string;
|
|
62
|
+
/**
|
|
63
|
+
* Under the dialog's field while what's typed isn't a site.
|
|
64
|
+
*/
|
|
65
|
+
invalid: string;
|
|
66
|
+
/**
|
|
67
|
+
* The Remove button's name for screen readers.
|
|
68
|
+
*/
|
|
69
|
+
removeLabel: (label: string) => string;
|
|
70
|
+
/**
|
|
71
|
+
* The ⋮ button's name for screen readers.
|
|
72
|
+
*/
|
|
73
|
+
moreActions: (label: string) => string;
|
|
74
|
+
};
|
|
75
|
+
/**
|
|
76
|
+
* The list's words, Chrome's English by default; a page passes its translations.
|
|
77
|
+
*
|
|
78
|
+
* @typedef {object} SiteListWords
|
|
79
|
+
* @property {string} add The Add button in the dialog.
|
|
80
|
+
* @property {string} addSite The dialog's title when adding.
|
|
81
|
+
* @property {string} editSite The dialog's title when editing.
|
|
82
|
+
* @property {string} edit The ⋮ menu's Edit item.
|
|
83
|
+
* @property {string} remove The Remove button, or the ⋮ menu's Remove item.
|
|
84
|
+
* @property {string} save The dialog's action when editing.
|
|
85
|
+
* @property {string} cancel The dialog's Cancel button.
|
|
86
|
+
* @property {string} site The dialog's field label.
|
|
87
|
+
* @property {string} invalid Under the dialog's field while what's typed isn't a site.
|
|
88
|
+
* @property {(label: string) => string} removeLabel The Remove button's name for screen readers.
|
|
89
|
+
* @property {(label: string) => string} moreActions The ⋮ button's name for screen readers.
|
|
90
|
+
*/
|
|
91
|
+
/** @type {Readonly<SiteListWords>} */
|
|
92
|
+
export declare const SITE_LIST_WORDS: Readonly<SiteListWords>;
|
|
93
|
+
export type SiteListOptions = {
|
|
94
|
+
/**
|
|
95
|
+
* The `<ul class="list">` to fill.
|
|
96
|
+
*/
|
|
97
|
+
list: HTMLElement;
|
|
98
|
+
/**
|
|
99
|
+
* Shown while the list is empty.
|
|
100
|
+
*/
|
|
101
|
+
empty?: HTMLElement;
|
|
102
|
+
/**
|
|
103
|
+
* An add row: a form with one text field and a submit button.
|
|
104
|
+
*/
|
|
105
|
+
form?: HTMLFormElement;
|
|
106
|
+
/**
|
|
107
|
+
* Or an Add button that asks in the dialog; each row then has a
|
|
108
|
+
* ⋮ menu (Edit, Remove) instead of a Remove button.
|
|
109
|
+
*/
|
|
110
|
+
addButton?: HTMLElement;
|
|
111
|
+
/**
|
|
112
|
+
* What was typed as the site the list keeps
|
|
113
|
+
* (`siteHost`, `siteOrigin`), or null when it isn't one.
|
|
114
|
+
*/
|
|
115
|
+
parse: (text: string) => string | null;
|
|
116
|
+
/**
|
|
117
|
+
* Adds a parsed site (asking Chrome for access first, if
|
|
118
|
+
* the list needs it). Resolve to false to keep the text in the add row's field (access refused).
|
|
119
|
+
*/
|
|
120
|
+
add: (site: string) => unknown;
|
|
121
|
+
/**
|
|
122
|
+
* Removes a site.
|
|
123
|
+
*/
|
|
124
|
+
remove: (site: string) => unknown;
|
|
125
|
+
/**
|
|
126
|
+
* Puts an edited site in place of
|
|
127
|
+
* the old one (the ⋮ menu's Edit; without it, the menu has only Remove).
|
|
128
|
+
*/
|
|
129
|
+
replace?: (site: string, edited: string) => unknown;
|
|
130
|
+
/**
|
|
131
|
+
* The add row: called with what was typed when it isn't a site.
|
|
132
|
+
*/
|
|
133
|
+
invalid?: (text: string) => void;
|
|
134
|
+
/**
|
|
135
|
+
* How a site reads. Default {@link siteLabel}.
|
|
136
|
+
*/
|
|
137
|
+
label?: (site: string) => string;
|
|
138
|
+
/**
|
|
139
|
+
* The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
|
|
140
|
+
*/
|
|
141
|
+
maxLength?: number;
|
|
142
|
+
/**
|
|
143
|
+
* Translations of {@link SITE_LIST_WORDS}.
|
|
144
|
+
*/
|
|
145
|
+
words?: Partial<SiteListWords>;
|
|
146
|
+
};
|
|
147
|
+
export type SiteList = {
|
|
148
|
+
/**
|
|
149
|
+
* Show these sites (call it whenever they change).
|
|
150
|
+
*/
|
|
151
|
+
render: (sites: readonly string[]) => void;
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* What {@link createSiteList} takes: the list, one way to add to it, and
|
|
155
|
+
* what adding, removing and editing do.
|
|
156
|
+
*
|
|
157
|
+
* @typedef {object} SiteListOptions
|
|
158
|
+
* @property {HTMLElement} list The `<ul class="list">` to fill.
|
|
159
|
+
* @property {HTMLElement} [empty] Shown while the list is empty.
|
|
160
|
+
* @property {HTMLFormElement} [form] An add row: a form with one text field and a submit button.
|
|
161
|
+
* @property {HTMLElement} [addButton] Or an Add button that asks in the dialog; each row then has a
|
|
162
|
+
* ⋮ menu (Edit, Remove) instead of a Remove button.
|
|
163
|
+
* @property {(text: string) => string | null} parse What was typed as the site the list keeps
|
|
164
|
+
* (`siteHost`, `siteOrigin`), or null when it isn't one.
|
|
165
|
+
* @property {(site: string) => unknown} add Adds a parsed site (asking Chrome for access first, if
|
|
166
|
+
* the list needs it). Resolve to false to keep the text in the add row's field (access refused).
|
|
167
|
+
* @property {(site: string) => unknown} remove Removes a site.
|
|
168
|
+
* @property {(site: string, edited: string) => unknown} [replace] Puts an edited site in place of
|
|
169
|
+
* the old one (the ⋮ menu's Edit; without it, the menu has only Remove).
|
|
170
|
+
* @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
|
|
171
|
+
* @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
|
|
172
|
+
* @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
|
|
173
|
+
* @property {Partial<SiteListWords>} [words] Translations of {@link SITE_LIST_WORDS}.
|
|
174
|
+
*/
|
|
175
|
+
/**
|
|
176
|
+
* A site list.
|
|
177
|
+
*
|
|
178
|
+
* @typedef {object} SiteList
|
|
179
|
+
* @property {(sites: readonly string[]) => void} render Show these sites (call it whenever they change).
|
|
180
|
+
*/
|
|
181
|
+
/**
|
|
182
|
+
* Fill a site list and wire its way of adding: an add row (typing a site and
|
|
183
|
+
* pressing Enter or Add) or an Add button (the dialog, which keeps its Add off
|
|
184
|
+
* until what's typed is a site). A row's Remove (or its ⋮ menu's Remove and
|
|
185
|
+
* Edit) hands its site to `remove` (or `replace`).
|
|
186
|
+
*
|
|
187
|
+
* @param {SiteListOptions} options - The markup, the parser and what adding and removing do.
|
|
188
|
+
* @returns {SiteList} The list.
|
|
189
|
+
* @example
|
|
190
|
+
* const never = createSiteList({
|
|
191
|
+
* list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
|
|
192
|
+
* parse: siteHost,
|
|
193
|
+
* add: (host) => save({ neverSites: [...settings.neverSites, host] }),
|
|
194
|
+
* remove: (host) => save({ neverSites: settings.neverSites.filter((h) => h !== host) }),
|
|
195
|
+
* words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
|
|
196
|
+
* });
|
|
197
|
+
* never.render(settings.neverSites);
|
|
198
|
+
*/
|
|
199
|
+
export declare function createSiteList({ list, empty, form, addButton, parse, add, remove, replace, invalid, label, maxLength, words, }: SiteListOptions): SiteList;
|
|
@@ -0,0 +1,194 @@
|
|
|
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
|
+
* What {@link createSiteList} takes: the list, one way to add to it, and
|
|
68
|
+
* what adding, removing and editing do.
|
|
69
|
+
*
|
|
70
|
+
* @typedef {object} SiteListOptions
|
|
71
|
+
* @property {HTMLElement} list The `<ul class="list">` to fill.
|
|
72
|
+
* @property {HTMLElement} [empty] Shown while the list is empty.
|
|
73
|
+
* @property {HTMLFormElement} [form] An add row: a form with one text field and a submit button.
|
|
74
|
+
* @property {HTMLElement} [addButton] Or an Add button that asks in the dialog; each row then has a
|
|
75
|
+
* ⋮ menu (Edit, Remove) instead of a Remove button.
|
|
76
|
+
* @property {(text: string) => string | null} parse What was typed as the site the list keeps
|
|
77
|
+
* (`siteHost`, `siteOrigin`), or null when it isn't one.
|
|
78
|
+
* @property {(site: string) => unknown} add Adds a parsed site (asking Chrome for access first, if
|
|
79
|
+
* the list needs it). Resolve to false to keep the text in the add row's field (access refused).
|
|
80
|
+
* @property {(site: string) => unknown} remove Removes a site.
|
|
81
|
+
* @property {(site: string, edited: string) => unknown} [replace] Puts an edited site in place of
|
|
82
|
+
* the old one (the ⋮ menu's Edit; without it, the menu has only Remove).
|
|
83
|
+
* @property {(text: string) => void} [invalid] The add row: called with what was typed when it isn't a site.
|
|
84
|
+
* @property {(site: string) => string} [label] How a site reads. Default {@link siteLabel}.
|
|
85
|
+
* @property {number} [maxLength] The dialog field's `maxlength`. Default {@link MAX_HOST_LENGTH}.
|
|
86
|
+
* @property {Partial<SiteListWords>} [words] Translations of {@link SITE_LIST_WORDS}.
|
|
87
|
+
*/
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A site list.
|
|
91
|
+
*
|
|
92
|
+
* @typedef {object} SiteList
|
|
93
|
+
* @property {(sites: readonly string[]) => void} render Show these sites (call it whenever they change).
|
|
94
|
+
*/
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Fill a site list and wire its way of adding: an add row (typing a site and
|
|
98
|
+
* pressing Enter or Add) or an Add button (the dialog, which keeps its Add off
|
|
99
|
+
* until what's typed is a site). A row's Remove (or its ⋮ menu's Remove and
|
|
100
|
+
* Edit) hands its site to `remove` (or `replace`).
|
|
101
|
+
*
|
|
102
|
+
* @param {SiteListOptions} options - The markup, the parser and what adding and removing do.
|
|
103
|
+
* @returns {SiteList} The list.
|
|
104
|
+
* @example
|
|
105
|
+
* const never = createSiteList({
|
|
106
|
+
* list: byId('never-sites'), form: byId('add-never', HTMLFormElement), empty: byId('no-sites'),
|
|
107
|
+
* parse: siteHost,
|
|
108
|
+
* add: (host) => save({ neverSites: [...settings.neverSites, host] }),
|
|
109
|
+
* remove: (host) => save({ neverSites: settings.neverSites.filter((h) => h !== host) }),
|
|
110
|
+
* words: { remove: t('optionsRemove'), removeLabel: (site) => t('optionsRemoveLabel', [site]) },
|
|
111
|
+
* });
|
|
112
|
+
* never.render(settings.neverSites);
|
|
113
|
+
*/
|
|
114
|
+
export function createSiteList({
|
|
115
|
+
list, empty, form, addButton, parse, add, remove, replace, invalid, label = siteLabel, maxLength = MAX_HOST_LENGTH, words,
|
|
116
|
+
}) {
|
|
117
|
+
const doc = list.ownerDocument;
|
|
118
|
+
const w = { ...SITE_LIST_WORDS, ...words };
|
|
119
|
+
const menu = addButton ? createMenu({ doc }) : null;
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Ask for a site in the dialog.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} title - Its title.
|
|
125
|
+
* @param {string} action - Its action button.
|
|
126
|
+
* @param {string} [value] - The site to start with.
|
|
127
|
+
* @returns {Promise<string | null>} The site, parsed, or null when cancelled.
|
|
128
|
+
*/
|
|
129
|
+
const ask = async (title, action, value) => {
|
|
130
|
+
const text = await askText({
|
|
131
|
+
title, label: w.site, value, action, cancel: w.cancel, maxLength, doc,
|
|
132
|
+
validate: (typed) => (parse(typed) ? null : w.invalid),
|
|
133
|
+
});
|
|
134
|
+
return text === null ? null : parse(text);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
form?.addEventListener('submit', async (event) => {
|
|
138
|
+
event.preventDefault();
|
|
139
|
+
const input = /** @type {HTMLInputElement} */ (form.querySelector('input'));
|
|
140
|
+
const site = parse(input.value);
|
|
141
|
+
if (!site) invalid?.(input.value);
|
|
142
|
+
else if (await add(site) !== false) input.value = '';
|
|
143
|
+
});
|
|
144
|
+
addButton?.addEventListener('click', async () => {
|
|
145
|
+
const site = await ask(w.addSite, w.add);
|
|
146
|
+
if (site) await add(site);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The end of a site's row: its ⋮ button, or its Remove button.
|
|
151
|
+
*
|
|
152
|
+
* @param {string} site - The site.
|
|
153
|
+
* @param {string} name - How it reads.
|
|
154
|
+
* @returns {HTMLButtonElement} The button.
|
|
155
|
+
*/
|
|
156
|
+
const rowEnd = (site, name) => {
|
|
157
|
+
if (menu) {
|
|
158
|
+
return moreActionsButton(name, {
|
|
159
|
+
label: w.moreActions(name), doc,
|
|
160
|
+
onClick: (anchor) => menu.open([
|
|
161
|
+
...replace ? [{
|
|
162
|
+
label: w.edit,
|
|
163
|
+
run: async () => {
|
|
164
|
+
const edited = await ask(w.editSite, w.save, name);
|
|
165
|
+
if (edited && edited !== site) await replace(site, edited);
|
|
166
|
+
},
|
|
167
|
+
}] : [],
|
|
168
|
+
{ label: w.remove, run: () => remove(site) },
|
|
169
|
+
], { anchor }),
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
const button = doc.createElement('button');
|
|
173
|
+
button.type = 'button';
|
|
174
|
+
button.textContent = w.remove;
|
|
175
|
+
button.setAttribute('aria-label', w.removeLabel(name));
|
|
176
|
+
button.addEventListener('click', () => remove(site));
|
|
177
|
+
return button;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
render(sites) {
|
|
182
|
+
list.replaceChildren(...sites.map((site) => {
|
|
183
|
+
const row = doc.createElement('li');
|
|
184
|
+
row.className = 'row';
|
|
185
|
+
const name = doc.createElement('span');
|
|
186
|
+
name.className = 'label';
|
|
187
|
+
name.textContent = label(site);
|
|
188
|
+
row.append(name, rowEnd(site, name.textContent));
|
|
189
|
+
return row;
|
|
190
|
+
}));
|
|
191
|
+
if (empty) empty.hidden = sites.length > 0;
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
package/src/ui/text-dialog.js
CHANGED
|
@@ -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
|
|
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
|
-
*
|
|
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
|
|
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 {
|
|
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
|
|
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"
|
|
63
|
-
* `#toast-text` and an Undo button `#toast-undo`,
|
|
64
|
-
* when the page loads, so screen readers know the
|
|
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
|
|
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;
|