@arraypress/extension-kit 1.1.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.
Files changed (48) hide show
  1. package/README.md +166 -14
  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/setup.d.ts +54 -0
  19. package/src/dom/setup.js +46 -0
  20. package/src/index.d.ts +29 -2
  21. package/src/index.js +23 -2
  22. package/src/sites/sites.d.ts +82 -0
  23. package/src/sites/sites.js +128 -0
  24. package/src/styles/chrome-ui.css +18 -7
  25. package/src/testing/browser.d.ts +52 -0
  26. package/src/testing/browser.js +79 -0
  27. package/src/testing/e2e.d.ts +18 -5
  28. package/src/testing/e2e.js +17 -5
  29. package/src/testing/index.d.ts +15 -4
  30. package/src/testing/index.js +15 -4
  31. package/src/testing/package.d.ts +62 -4
  32. package/src/testing/package.js +39 -8
  33. package/src/testing/pool.d.ts +23 -0
  34. package/src/testing/pool.js +33 -0
  35. package/src/testing/saved-groups.d.ts +33 -0
  36. package/src/testing/saved-groups.js +222 -0
  37. package/src/testing/time.d.ts +35 -0
  38. package/src/testing/time.js +46 -0
  39. package/src/testing/warnings.d.ts +57 -0
  40. package/src/testing/warnings.js +69 -0
  41. package/src/ui/menu.d.ts +257 -0
  42. package/src/ui/menu.js +283 -0
  43. package/src/ui/site-list.d.ts +199 -0
  44. package/src/ui/site-list.js +194 -0
  45. package/src/ui/text-dialog.d.ts +132 -0
  46. package/src/ui/text-dialog.js +187 -0
  47. package/src/ui/toast.d.ts +89 -0
  48. package/src/ui/toast.js +103 -0
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Chrome's own install warnings for a manifest ("Read your browsing
3
+ * history"), the lines its install dialog and the Chrome Web Store list, read
4
+ * with `chrome.management.getPermissionWarningsByManifest` (no `management`
5
+ * permission needed) in a loaded extension's service worker. For an e2e check
6
+ * that a permission adds no warning, or that the warnings are exactly the
7
+ * ones the store listing explains.
8
+ *
9
+ * @module testing/warnings
10
+ */
11
+
12
+ /** @import { Worker } from 'playwright' */
13
+
14
+ /**
15
+ * A manifest, as `manifest.json` parses.
16
+ *
17
+ * @typedef {Record<string, unknown>} Manifest
18
+ */
19
+
20
+ /**
21
+ * The manifest less some permissions (from `permissions`, `optional_permissions`
22
+ * and `host_permissions`). Pure: `manifest` is left as it was.
23
+ *
24
+ * @param {Manifest} manifest - The manifest.
25
+ * @param {readonly string[]} names - The permissions (or host patterns) to leave out.
26
+ * @returns {Manifest} A copy without them.
27
+ * @example
28
+ * withoutPermissions({permissions: ['tabs', 'alarms']}, ['alarms']); // → {permissions: ['tabs']}
29
+ */
30
+ export function withoutPermissions(manifest, names) {
31
+ const copy = { ...manifest };
32
+ for (const key of ['permissions', 'optional_permissions', 'host_permissions']) {
33
+ const list = manifest[key];
34
+ if (Array.isArray(list)) copy[key] = list.filter((name) => !names.includes(name));
35
+ }
36
+ return copy;
37
+ }
38
+
39
+ /**
40
+ * Chrome's install warnings for a manifest, as this browser words them.
41
+ *
42
+ * @param {Worker} worker - A loaded extension's service worker (`launchExtension`'s `worker`).
43
+ * @param {Manifest} manifest - The manifest to ask about (it needn't be the loaded extension's).
44
+ * @returns {Promise<string[]>} The warnings, in Chrome's order.
45
+ * @example
46
+ * assert.deepEqual(await installWarnings(worker, manifest), ['Read your browsing history']);
47
+ */
48
+ export function installWarnings(worker, manifest) {
49
+ return worker.evaluate((text) => chrome.management.getPermissionWarningsByManifest(text), JSON.stringify(manifest));
50
+ }
51
+
52
+ /**
53
+ * Chrome's install warnings for a manifest, with and without some permissions,
54
+ * and the ones only the permissions bring (a warning they reword counts).
55
+ *
56
+ * @param {Worker} worker - A loaded extension's service worker.
57
+ * @param {Manifest} manifest - The manifest.
58
+ * @param {readonly string[]} names - The permissions in question.
59
+ * @returns {Promise<{warnings: string[], without: string[], added: string[]}>} The warnings with
60
+ * them, without them, and those in the first list only.
61
+ * @example
62
+ * const { warnings, added } = await warningsAddedBy(worker, manifest, ['alarms']);
63
+ * assert.deepEqual(added, []); // alarms asks the user nothing
64
+ */
65
+ export async function warningsAddedBy(worker, manifest, names) {
66
+ const warnings = await installWarnings(worker, manifest);
67
+ const without = await installWarnings(worker, withoutPermissions(manifest, names));
68
+ return { warnings, without, added: warnings.filter((warning) => !without.includes(warning)) };
69
+ }
@@ -0,0 +1,257 @@
1
+ /**
2
+ * Chrome's action menu (cr-action-menu), the ⋮ menu of chrome://bookmarks and
3
+ * chrome://settings: a `<dialog class="menu">` of `role="menuitem"` buttons,
4
+ * shown modally over the button that opened it (or at the pointer, for a
5
+ * right-click) and kept on screen. A click outside it, Esc, Tab or the window
6
+ * losing focus closes it; ↑/↓ (wrapping), Home and End move between its
7
+ * items; and focus goes back to where it came from. Also the rows' ⋮ button,
8
+ * labelled "More actions for …" as Chrome labels its own.
9
+ *
10
+ * Sources: ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.ts
11
+ * (a modal `<dialog>`; a click on its backdrop closes it; the anchor gets the
12
+ * focus back; ↑/↓ wrap) and cr_icon_button with cr:more-vert.
13
+ *
14
+ * @module ui/menu
15
+ */
16
+ /** The gap kept between a menu and the window's edges, in px. */
17
+ export declare const MENU_EDGE = 8;
18
+ export type MenuItem = {
19
+ /**
20
+ * Its text ("Rename").
21
+ */
22
+ label: string;
23
+ /**
24
+ * Runs once the menu has closed.
25
+ */
26
+ run: () => unknown;
27
+ /**
28
+ * Greyed out, and skipped by the arrow keys.
29
+ */
30
+ disabled?: boolean;
31
+ };
32
+ export type MenuEntry = MenuItem | 'separator';
33
+ export type MenuPlace = {
34
+ /**
35
+ * The button that opened it.
36
+ */
37
+ anchor?: HTMLElement;
38
+ /**
39
+ * Without an anchor: the pointer's x (a right-click's `clientX`).
40
+ */
41
+ x?: number;
42
+ /**
43
+ * Without an anchor: the pointer's y.
44
+ */
45
+ y?: number;
46
+ /**
47
+ * The menu's name for screen readers ("Actions for Research").
48
+ */
49
+ label?: string;
50
+ /**
51
+ * Where focus goes when it closes. Default the anchor, else what had focus.
52
+ */
53
+ focusTo?: HTMLElement;
54
+ };
55
+ export type Box = {
56
+ left: number;
57
+ top: number;
58
+ right: number;
59
+ bottom: number;
60
+ };
61
+ /**
62
+ * One item: its text, what it does, and whether it's greyed out.
63
+ *
64
+ * @typedef {object} MenuItem
65
+ * @property {string} label Its text ("Rename").
66
+ * @property {() => unknown} run Runs once the menu has closed.
67
+ * @property {boolean} [disabled] Greyed out, and skipped by the arrow keys.
68
+ */
69
+ /**
70
+ * An item, or `'separator'` for a line between groups of items.
71
+ *
72
+ * @typedef {MenuItem | 'separator'} MenuEntry
73
+ */
74
+ /**
75
+ * Where a menu opens: over (or below) the button that opened it, or at a
76
+ * pointer position.
77
+ *
78
+ * @typedef {object} MenuPlace
79
+ * @property {HTMLElement} [anchor] The button that opened it.
80
+ * @property {number} [x] Without an anchor: the pointer's x (a right-click's `clientX`).
81
+ * @property {number} [y] Without an anchor: the pointer's y.
82
+ * @property {string} [label] The menu's name for screen readers ("Actions for Research").
83
+ * @property {HTMLElement} [focusTo] Where focus goes when it closes. Default the anchor, else what had focus.
84
+ */
85
+ /**
86
+ * A rectangle in the window, as `getBoundingClientRect()` gives it.
87
+ *
88
+ * @typedef {{left: number, top: number, right: number, bottom: number}} Box
89
+ */
90
+ /**
91
+ * Where to put a menu, so it stays on screen. Pure.
92
+ *
93
+ * Over an anchor (Chrome's way): the menu's top at the anchor's top and its
94
+ * end at the anchor's end; moved up to end at the anchor's bottom when it
95
+ * would leave the window below. Below an anchor: 4px under it, or above it
96
+ * when there's no room. At a point: its top-left there, or its bottom-left
97
+ * when there's no room below. Always at least {@link MENU_EDGE} from the
98
+ * window's edges.
99
+ *
100
+ * @param {{width: number, height: number}} size - The menu's size.
101
+ * @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
102
+ * @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The anchor's
103
+ * box, or a point; `placement` says where it goes from an anchor (default `'over'`).
104
+ * @returns {{left: number, top: number}} The menu's top-left, in px.
105
+ * @example
106
+ * menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
107
+ * // → {left: anchor.right - 160, top: anchor.top}
108
+ */
109
+ export declare function menuPosition(size: {
110
+ width: number;
111
+ height: number;
112
+ }, viewport: {
113
+ width: number;
114
+ height: number;
115
+ }, { anchor, x, y, placement }: {
116
+ anchor?: Box;
117
+ x?: number;
118
+ y?: number;
119
+ placement?: 'over' | 'below';
120
+ }): {
121
+ left: number;
122
+ top: number;
123
+ };
124
+ /**
125
+ * Whether a point is inside a box (edges included). Pure.
126
+ *
127
+ * @param {Box} box - The box.
128
+ * @param {number} x - The point's x.
129
+ * @param {number} y - The point's y.
130
+ * @returns {boolean} True when inside.
131
+ */
132
+ export declare function isInside(box: Box, x: number, y: number): boolean;
133
+ export type MenuOptions = {
134
+ /**
135
+ * The menu element's id.
136
+ */
137
+ id?: string;
138
+ /**
139
+ * Where it opens from a button. Default `'over'`, as Chrome's.
140
+ */
141
+ placement?: 'over' | 'below';
142
+ /**
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`.
149
+ */
150
+ doc?: Document;
151
+ };
152
+ export type ActionMenu = {
153
+ /**
154
+ * The `<dialog class="menu" role="menu">`, in `root`.
155
+ */
156
+ element: HTMLDialogElement;
157
+ /**
158
+ * Fill it with `entries`
159
+ * and open it (closing it first if it's open).
160
+ */
161
+ open: (entries: readonly MenuEntry[], where: MenuPlace) => void;
162
+ /**
163
+ * Close it, if open, and return the focus.
164
+ */
165
+ close: () => void;
166
+ };
167
+ /**
168
+ * What {@link createMenu} takes.
169
+ *
170
+ * @typedef {object} MenuOptions
171
+ * @property {string} [id] The menu element's id.
172
+ * @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
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`.
176
+ */
177
+ /**
178
+ * A page's action menu.
179
+ *
180
+ * @typedef {object} ActionMenu
181
+ * @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in `root`.
182
+ * @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
183
+ * and open it (closing it first if it's open).
184
+ * @property {() => void} close Close it, if open, and return the focus.
185
+ */
186
+ /**
187
+ * Make a page's action menu: one `<dialog class="menu">` that any number of
188
+ * ⋮ buttons (or right-clicks) open with their own items.
189
+ *
190
+ * @param {MenuOptions} [options] - Its id, placement, and where it goes.
191
+ * @returns {ActionMenu} The menu.
192
+ * @example
193
+ * const menu = createMenu();
194
+ * row.append(moreActionsButton(workspace.name, {
195
+ * onClick: (button) => menu.open([
196
+ * {label: 'Rename', run: () => rename(workspace)},
197
+ * 'separator',
198
+ * {label: 'Delete', run: () => remove(workspace)},
199
+ * ], {anchor: button}),
200
+ * }));
201
+ */
202
+ export declare function createMenu({ id, placement, root, doc }?: MenuOptions): ActionMenu;
203
+ export type MoreActionsOptions = {
204
+ /**
205
+ * The whole label, for a page that translates it. Default
206
+ * `More actions for “<name>”`, Chrome's words.
207
+ */
208
+ label?: string;
209
+ /**
210
+ * More classes, after `icon-button`.
211
+ */
212
+ className?: string;
213
+ /**
214
+ * In the tab order. Default true; false for a row that takes the
215
+ * focus itself and opens the menu from the keyboard.
216
+ */
217
+ focusable?: boolean;
218
+ /**
219
+ * Show the label on hover too (`title`). Default false.
220
+ */
221
+ tooltip?: boolean;
222
+ /**
223
+ * Opens its menu.
224
+ */
225
+ onClick?: (button: HTMLButtonElement, event: MouseEvent) => void;
226
+ /**
227
+ * The page. Default `document`.
228
+ */
229
+ doc?: Document;
230
+ };
231
+ /**
232
+ * What {@link moreActionsButton} takes.
233
+ *
234
+ * @typedef {object} MoreActionsOptions
235
+ * @property {string} [label] The whole label, for a page that translates it. Default
236
+ * `More actions for “<name>”`, Chrome's words.
237
+ * @property {string} [className] More classes, after `icon-button`.
238
+ * @property {boolean} [focusable] In the tab order. Default true; false for a row that takes the
239
+ * focus itself and opens the menu from the keyboard.
240
+ * @property {boolean} [tooltip] Show the label on hover too (`title`). Default false.
241
+ * @property {(button: HTMLButtonElement, event: MouseEvent) => void} [onClick] Opens its menu.
242
+ * @property {Document} [doc] The page. Default `document`.
243
+ */
244
+ /**
245
+ * A row's ⋮ button: a 32px cr-icon-button with cr:more-vert (the
246
+ * stylesheet's `--icon-more-vert`, so the page needs no sprite), labelled for
247
+ * screen readers as Chrome labels its own.
248
+ *
249
+ * @param {string} name - What the row is ("Research"), for "More actions for “Research”".
250
+ * @param {MoreActionsOptions} [options] - Its label, classes, tab stop, tooltip and click.
251
+ * @returns {HTMLButtonElement} The button.
252
+ * @example
253
+ * row.append(moreActionsButton(site, {onClick: (button) => menu.open(siteItems(site), {anchor: button})}));
254
+ * // A translated label:
255
+ * moreActionsButton(name, {label: t('rowActions', name), className: 'row-menu', focusable: false});
256
+ */
257
+ export declare function moreActionsButton(name: string, { label, className, focusable, tooltip, onClick, doc }?: MoreActionsOptions): HTMLButtonElement;
package/src/ui/menu.js ADDED
@@ -0,0 +1,283 @@
1
+ /**
2
+ * Chrome's action menu (cr-action-menu), the ⋮ menu of chrome://bookmarks and
3
+ * chrome://settings: a `<dialog class="menu">` of `role="menuitem"` buttons,
4
+ * shown modally over the button that opened it (or at the pointer, for a
5
+ * right-click) and kept on screen. A click outside it, Esc, Tab or the window
6
+ * losing focus closes it; ↑/↓ (wrapping), Home and End move between its
7
+ * items; and focus goes back to where it came from. Also the rows' ⋮ button,
8
+ * labelled "More actions for …" as Chrome labels its own.
9
+ *
10
+ * Sources: ui/webui/resources/cr_elements/cr_action_menu/cr_action_menu.ts
11
+ * (a modal `<dialog>`; a click on its backdrop closes it; the anchor gets the
12
+ * focus back; ↑/↓ wrap) and cr_icon_button with cr:more-vert.
13
+ *
14
+ * @module ui/menu
15
+ */
16
+
17
+ import { focusedElement, rootDocument } from '../dom/focus.js';
18
+
19
+ /** The gap kept between a menu and the window's edges, in px. */
20
+ export const MENU_EDGE = 8;
21
+
22
+ /** The gap between a button and a menu placed below it, in px. */
23
+ const BELOW_GAP = 4;
24
+
25
+ /**
26
+ * One item: its text, what it does, and whether it's greyed out.
27
+ *
28
+ * @typedef {object} MenuItem
29
+ * @property {string} label Its text ("Rename").
30
+ * @property {() => unknown} run Runs once the menu has closed.
31
+ * @property {boolean} [disabled] Greyed out, and skipped by the arrow keys.
32
+ */
33
+
34
+ /**
35
+ * An item, or `'separator'` for a line between groups of items.
36
+ *
37
+ * @typedef {MenuItem | 'separator'} MenuEntry
38
+ */
39
+
40
+ /**
41
+ * Where a menu opens: over (or below) the button that opened it, or at a
42
+ * pointer position.
43
+ *
44
+ * @typedef {object} MenuPlace
45
+ * @property {HTMLElement} [anchor] The button that opened it.
46
+ * @property {number} [x] Without an anchor: the pointer's x (a right-click's `clientX`).
47
+ * @property {number} [y] Without an anchor: the pointer's y.
48
+ * @property {string} [label] The menu's name for screen readers ("Actions for Research").
49
+ * @property {HTMLElement} [focusTo] Where focus goes when it closes. Default the anchor, else what had focus.
50
+ */
51
+
52
+ /**
53
+ * A rectangle in the window, as `getBoundingClientRect()` gives it.
54
+ *
55
+ * @typedef {{left: number, top: number, right: number, bottom: number}} Box
56
+ */
57
+
58
+ /**
59
+ * Where to put a menu, so it stays on screen. Pure.
60
+ *
61
+ * Over an anchor (Chrome's way): the menu's top at the anchor's top and its
62
+ * end at the anchor's end; moved up to end at the anchor's bottom when it
63
+ * would leave the window below. Below an anchor: 4px under it, or above it
64
+ * when there's no room. At a point: its top-left there, or its bottom-left
65
+ * when there's no room below. Always at least {@link MENU_EDGE} from the
66
+ * window's edges.
67
+ *
68
+ * @param {{width: number, height: number}} size - The menu's size.
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'`).
72
+ * @returns {{left: number, top: number}} The menu's top-left, in px.
73
+ * @example
74
+ * menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
75
+ * // → {left: anchor.right - 160, top: anchor.top}
76
+ */
77
+ export function menuPosition(size, viewport, { anchor, x = 0, y = 0, placement = 'over' }) {
78
+ const { width, height } = size;
79
+ const left = (/** @type {number} */ start) => Math.max(MENU_EDGE, Math.min(start, viewport.width - width - MENU_EDGE));
80
+ 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 (placement === 'below') {
84
+ const below = anchor.bottom + BELOW_GAP;
85
+ return { left: start, top: Math.max(MENU_EDGE, room(below) ? below : anchor.top - height - BELOW_GAP) };
86
+ }
87
+ return { left: start, top: Math.max(MENU_EDGE, room(anchor.top) ? anchor.top : anchor.bottom - height) };
88
+ }
89
+
90
+ /**
91
+ * Whether a point is inside a box (edges included). Pure.
92
+ *
93
+ * @param {Box} box - The box.
94
+ * @param {number} x - The point's x.
95
+ * @param {number} y - The point's y.
96
+ * @returns {boolean} True when inside.
97
+ */
98
+ export function isInside(box, x, y) {
99
+ return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
100
+ }
101
+
102
+ /**
103
+ * What {@link createMenu} takes.
104
+ *
105
+ * @typedef {object} MenuOptions
106
+ * @property {string} [id] The menu element's id.
107
+ * @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
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`.
111
+ */
112
+
113
+ /**
114
+ * A page's action menu.
115
+ *
116
+ * @typedef {object} ActionMenu
117
+ * @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in `root`.
118
+ * @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
119
+ * and open it (closing it first if it's open).
120
+ * @property {() => void} close Close it, if open, and return the focus.
121
+ */
122
+
123
+ /**
124
+ * Make a page's action menu: one `<dialog class="menu">` that any number of
125
+ * ⋮ buttons (or right-clicks) open with their own items.
126
+ *
127
+ * @param {MenuOptions} [options] - Its id, placement, and where it goes.
128
+ * @returns {ActionMenu} The menu.
129
+ * @example
130
+ * const menu = createMenu();
131
+ * row.append(moreActionsButton(workspace.name, {
132
+ * onClick: (button) => menu.open([
133
+ * {label: 'Rename', run: () => rename(workspace)},
134
+ * 'separator',
135
+ * {label: 'Delete', run: () => remove(workspace)},
136
+ * ], {anchor: button}),
137
+ * }));
138
+ */
139
+ export function createMenu({ id, placement = 'over', root, doc = rootDocument(root) } = {}) {
140
+ const view = /** @type {Window & typeof globalThis} */ (doc.defaultView);
141
+ const menu = doc.createElement('dialog');
142
+ menu.className = 'menu';
143
+ menu.setAttribute('role', 'menu');
144
+ if (id) menu.id = id;
145
+ (root ?? doc.body).append(menu);
146
+
147
+ /** @type {HTMLElement | null} */
148
+ let returnFocus = null;
149
+ /** @type {HTMLElement | null} */
150
+ let expanded = null;
151
+
152
+ const items = () => /** @type {HTMLButtonElement[]} */ ([...menu.querySelectorAll('[role="menuitem"]:not(:disabled)')]);
153
+
154
+ /** Close without returning the focus (another open follows at once). */
155
+ const shut = () => {
156
+ if (menu.open) menu.close();
157
+ expanded?.setAttribute('aria-expanded', 'false');
158
+ expanded = null;
159
+ };
160
+
161
+ const close = () => {
162
+ if (!menu.open) return;
163
+ const target = returnFocus;
164
+ returnFocus = null;
165
+ shut();
166
+ if (target?.isConnected) target.focus();
167
+ };
168
+
169
+ menu.addEventListener('keydown', (event) => {
170
+ const list = items();
171
+ const at = list.indexOf(/** @type {HTMLButtonElement} */ (focusedElement(doc)));
172
+ /** @param {number} index */
173
+ const go = (index) => {
174
+ event.preventDefault();
175
+ list[(index + list.length) % list.length]?.focus();
176
+ };
177
+ if (event.key === 'ArrowDown') go(at + 1);
178
+ else if (event.key === 'ArrowUp') go(at < 0 ? -1 : at - 1);
179
+ else if (event.key === 'Home') go(0);
180
+ else if (event.key === 'End') go(-1);
181
+ else if (event.key === 'Escape' || event.key === 'Tab') {
182
+ event.preventDefault();
183
+ close();
184
+ }
185
+ });
186
+ // Esc the browser handles itself (when the keydown above didn't see it).
187
+ menu.addEventListener('cancel', (event) => {
188
+ event.preventDefault();
189
+ close();
190
+ });
191
+ // A modal dialog's backdrop is part of it: a click there targets the dialog, outside its box.
192
+ menu.addEventListener('click', (event) => {
193
+ if (event.target === menu && !isInside(menu.getBoundingClientRect(), event.clientX, event.clientY)) close();
194
+ });
195
+ view.addEventListener('blur', close);
196
+
197
+ return {
198
+ element: menu,
199
+ open(entries, { anchor, x, y, label, focusTo }) {
200
+ shut();
201
+ menu.replaceChildren(...entries.map((entry) => {
202
+ if (entry === 'separator') {
203
+ const line = doc.createElement('hr');
204
+ line.setAttribute('role', 'separator');
205
+ return line;
206
+ }
207
+ const button = doc.createElement('button');
208
+ button.type = 'button';
209
+ button.setAttribute('role', 'menuitem');
210
+ button.tabIndex = -1;
211
+ button.textContent = entry.label;
212
+ button.disabled = Boolean(entry.disabled);
213
+ button.addEventListener('click', () => {
214
+ close();
215
+ entry.run();
216
+ });
217
+ return button;
218
+ }));
219
+ if (label) menu.setAttribute('aria-label', label);
220
+ else menu.removeAttribute('aria-label');
221
+
222
+ const active = focusedElement(doc);
223
+ returnFocus = focusTo ?? anchor ?? (active instanceof view.HTMLElement ? active : null);
224
+ if (anchor?.hasAttribute('aria-haspopup')) {
225
+ anchor.setAttribute('aria-expanded', 'true');
226
+ expanded = anchor;
227
+ }
228
+ menu.showModal();
229
+ const { width, height } = menu.getBoundingClientRect();
230
+ const { left, top } = menuPosition({ width, height }, { width: view.innerWidth, height: view.innerHeight }, {
231
+ anchor: anchor?.getBoundingClientRect(), x, y, placement,
232
+ });
233
+ menu.style.left = `${left}px`;
234
+ menu.style.top = `${top}px`;
235
+ items()[0]?.focus();
236
+ },
237
+ close,
238
+ };
239
+ }
240
+
241
+ /**
242
+ * What {@link moreActionsButton} takes.
243
+ *
244
+ * @typedef {object} MoreActionsOptions
245
+ * @property {string} [label] The whole label, for a page that translates it. Default
246
+ * `More actions for “<name>”`, Chrome's words.
247
+ * @property {string} [className] More classes, after `icon-button`.
248
+ * @property {boolean} [focusable] In the tab order. Default true; false for a row that takes the
249
+ * focus itself and opens the menu from the keyboard.
250
+ * @property {boolean} [tooltip] Show the label on hover too (`title`). Default false.
251
+ * @property {(button: HTMLButtonElement, event: MouseEvent) => void} [onClick] Opens its menu.
252
+ * @property {Document} [doc] The page. Default `document`.
253
+ */
254
+
255
+ /**
256
+ * A row's ⋮ button: a 32px cr-icon-button with cr:more-vert (the
257
+ * stylesheet's `--icon-more-vert`, so the page needs no sprite), labelled for
258
+ * screen readers as Chrome labels its own.
259
+ *
260
+ * @param {string} name - What the row is ("Research"), for "More actions for “Research”".
261
+ * @param {MoreActionsOptions} [options] - Its label, classes, tab stop, tooltip and click.
262
+ * @returns {HTMLButtonElement} The button.
263
+ * @example
264
+ * row.append(moreActionsButton(site, {onClick: (button) => menu.open(siteItems(site), {anchor: button})}));
265
+ * // A translated label:
266
+ * moreActionsButton(name, {label: t('rowActions', name), className: 'row-menu', focusable: false});
267
+ */
268
+ export function moreActionsButton(name, { label, className, focusable = true, tooltip = false, onClick, doc = document } = {}) {
269
+ const text = label ?? `More actions for “${name}”`;
270
+ const button = doc.createElement('button');
271
+ button.type = 'button';
272
+ button.className = className ? `icon-button ${className}` : 'icon-button';
273
+ button.setAttribute('aria-label', text);
274
+ button.setAttribute('aria-haspopup', 'menu');
275
+ if (tooltip) button.title = text;
276
+ if (!focusable) button.tabIndex = -1;
277
+ const icon = doc.createElement('span');
278
+ icon.className = 'icon icon-more-vert';
279
+ icon.setAttribute('aria-hidden', 'true');
280
+ button.append(icon);
281
+ if (onClick) button.addEventListener('click', (event) => onClick(button, event));
282
+ return button;
283
+ }