@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.
@@ -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
- * The page. Default `document`.
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 the page's body.
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 {Document} [doc] The page. Default `document`.
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 the page's body.
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 page.
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 {Document} [doc] The page. Default `document`.
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 the page's body.
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 page.
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 = document } = {}) {
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.activeElement));
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.activeElement;
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
+ }
@@ -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;