@arraypress/extension-kit 1.1.0 → 1.2.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 +74 -5
- package/package.json +1 -1
- package/src/build/vendor.js +1 -1
- package/src/index.d.ts +22 -2
- package/src/index.js +17 -2
- package/src/styles/chrome-ui.css +5 -1
- package/src/testing/index.d.ts +9 -3
- package/src/testing/index.js +7 -3
- package/src/testing/package.d.ts +62 -4
- package/src/testing/package.js +39 -8
- package/src/testing/saved-groups.d.ts +33 -0
- package/src/testing/saved-groups.js +222 -0
- package/src/testing/warnings.d.ts +57 -0
- package/src/testing/warnings.js +69 -0
- package/src/ui/menu.d.ts +250 -0
- package/src/ui/menu.js +279 -0
- package/src/ui/text-dialog.d.ts +132 -0
- package/src/ui/text-dialog.js +185 -0
- package/src/ui/toast.d.ts +73 -0
- package/src/ui/toast.js +89 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's saved tab groups, read from a closed profile: no extension API lists them and their
|
|
3
|
+
* UI (the saved groups bar, the Tab groups menu) is native, so e2e checks read the store itself.
|
|
4
|
+
*
|
|
5
|
+
* Chromium keeps them in the profile's `Sync Data/LevelDB` (a DataTypeStore, kept even when
|
|
6
|
+
* signed out), one record per group and one per tab, keyed `saved_tab_group-dt-<guid>`. Each
|
|
7
|
+
* value is a `SavedTabGroupData` proto whose field 2 is the `SavedTabGroupSpecifics`
|
|
8
|
+
* (components/sync/protocol/saved_tab_group_specifics.proto): 1 guid, 4 group (2 title,
|
|
9
|
+
* 3 colour, 4 pinned position: present when pinned to the bar), 5 tab (1 group guid, 2 position,
|
|
10
|
+
* 3 url). A fresh test profile never fills a LevelDB log (4 MB) so everything is in `*.log`
|
|
11
|
+
* files, which this reads; it refuses a store that has compacted tables.
|
|
12
|
+
*
|
|
13
|
+
* Read only after the browser has closed, so every write is on disk.
|
|
14
|
+
*
|
|
15
|
+
* @module testing/saved-groups
|
|
16
|
+
*/
|
|
17
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A saved group as Chrome keeps it.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} SavedTabGroup
|
|
24
|
+
* @property {string} title Its title.
|
|
25
|
+
* @property {boolean} pinned Whether it's pinned to the saved groups bar.
|
|
26
|
+
* @property {string[]} urls Its tabs' addresses, in order.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One protobuf field: its number and value (a number for varints, the bytes for the rest).
|
|
31
|
+
*
|
|
32
|
+
* @typedef {{field: number, value: number | Buffer}} ProtoField
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const KEY_PREFIX = 'saved_tab_group-dt-';
|
|
36
|
+
/** LevelDB log blocks: 32 KB, each record with a 7-byte header (checksum, length, type). */
|
|
37
|
+
const BLOCK = 32768;
|
|
38
|
+
const HEADER = 7;
|
|
39
|
+
/** Record types: a whole batch, or its first, middle or last fragment. */
|
|
40
|
+
const FULL = 1;
|
|
41
|
+
const FIRST = 2;
|
|
42
|
+
const MIDDLE = 3;
|
|
43
|
+
const LAST = 4;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a varint (LevelDB lengths, protobuf keys and values). Numbers past 2^53 lose precision,
|
|
47
|
+
* which only affects timestamps this never reads.
|
|
48
|
+
*
|
|
49
|
+
* @param {Buffer} buf - The bytes.
|
|
50
|
+
* @param {number} at - Where it starts.
|
|
51
|
+
* @returns {[value: number, next: number]} Its value and the offset after it.
|
|
52
|
+
*/
|
|
53
|
+
function varint(buf, at) {
|
|
54
|
+
let value = 0;
|
|
55
|
+
let scale = 1;
|
|
56
|
+
let byte;
|
|
57
|
+
do {
|
|
58
|
+
byte = buf[at++];
|
|
59
|
+
value += (byte & 0x7f) * scale;
|
|
60
|
+
scale *= 128;
|
|
61
|
+
} while (byte & 0x80);
|
|
62
|
+
return [value, at];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The write batches in one LevelDB log file, fragments joined.
|
|
67
|
+
*
|
|
68
|
+
* @param {Buffer} buf - The file.
|
|
69
|
+
* @returns {Buffer[]} Its batches, in order.
|
|
70
|
+
*/
|
|
71
|
+
function batches(buf) {
|
|
72
|
+
/** @type {Buffer[]} */
|
|
73
|
+
const out = [];
|
|
74
|
+
/** @type {Buffer[]} */
|
|
75
|
+
let pending = [];
|
|
76
|
+
let at = 0;
|
|
77
|
+
while (at + HEADER <= buf.length) {
|
|
78
|
+
const left = BLOCK - (at % BLOCK);
|
|
79
|
+
if (left < HEADER) {
|
|
80
|
+
at += left; // Block trailer: zero padding.
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const length = buf.readUInt16LE(at + 4);
|
|
84
|
+
const type = buf[at + 6];
|
|
85
|
+
if (type === 0 && length === 0) {
|
|
86
|
+
at += left; // Pre-allocated, unwritten space.
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const data = buf.subarray(at + HEADER, at + HEADER + length);
|
|
90
|
+
at += HEADER + length;
|
|
91
|
+
if (type === FULL) out.push(data);
|
|
92
|
+
else if (type === FIRST) pending = [data];
|
|
93
|
+
else if (type === MIDDLE) pending.push(data);
|
|
94
|
+
else if (type === LAST) out.push(Buffer.concat([...pending, data]));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply one batch to the live keys: puts set a key, deletes remove it.
|
|
101
|
+
*
|
|
102
|
+
* @param {Buffer} batch - Sequence (8 bytes), count (4), then each tagged put or delete.
|
|
103
|
+
* @param {Map<string, Buffer>} live - The keys, changed in place.
|
|
104
|
+
*/
|
|
105
|
+
function apply(batch, live) {
|
|
106
|
+
const count = batch.readUInt32LE(8);
|
|
107
|
+
let at = 12;
|
|
108
|
+
for (let i = 0; i < count; i++) {
|
|
109
|
+
const put = batch[at++] === 1;
|
|
110
|
+
let length;
|
|
111
|
+
[length, at] = varint(batch, at);
|
|
112
|
+
const key = batch.subarray(at, at + length).toString('latin1');
|
|
113
|
+
at += length;
|
|
114
|
+
if (!put) {
|
|
115
|
+
live.delete(key);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
[length, at] = varint(batch, at);
|
|
119
|
+
live.set(key, batch.subarray(at, at + length));
|
|
120
|
+
at += length;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The fields of one protobuf message (one level).
|
|
126
|
+
*
|
|
127
|
+
* @param {Buffer} buf - The message.
|
|
128
|
+
* @returns {ProtoField[]} Its fields, in order.
|
|
129
|
+
*/
|
|
130
|
+
function fields(buf) {
|
|
131
|
+
/** @type {ProtoField[]} */
|
|
132
|
+
const out = [];
|
|
133
|
+
let at = 0;
|
|
134
|
+
while (at < buf.length) {
|
|
135
|
+
let key;
|
|
136
|
+
[key, at] = varint(buf, at);
|
|
137
|
+
const field = Math.floor(key / 8);
|
|
138
|
+
const wire = key % 8;
|
|
139
|
+
if (wire === 0) {
|
|
140
|
+
let value;
|
|
141
|
+
[value, at] = varint(buf, at);
|
|
142
|
+
out.push({ field, value });
|
|
143
|
+
} else if (wire === 2) {
|
|
144
|
+
let length;
|
|
145
|
+
[length, at] = varint(buf, at);
|
|
146
|
+
out.push({ field, value: buf.subarray(at, at + length) });
|
|
147
|
+
at += length;
|
|
148
|
+
} else if (wire === 1 || wire === 5) {
|
|
149
|
+
const size = wire === 1 ? 8 : 4;
|
|
150
|
+
out.push({ field, value: buf.subarray(at, at + size) });
|
|
151
|
+
at += size;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`Unexpected protobuf wire type ${wire}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A field's bytes.
|
|
161
|
+
*
|
|
162
|
+
* @param {ProtoField[]} message - The message's fields.
|
|
163
|
+
* @param {number} field - The field number.
|
|
164
|
+
* @returns {Buffer | undefined} Its bytes, when present and not a number.
|
|
165
|
+
*/
|
|
166
|
+
const bytesOf = (message, field) => {
|
|
167
|
+
const value = message.find((f) => f.field === field)?.value;
|
|
168
|
+
return Buffer.isBuffer(value) ? value : undefined;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Chrome's saved tab groups in a closed profile.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} profile - The profile folder (as `extensionProfile` gives it).
|
|
175
|
+
* @returns {Promise<SavedTabGroup[]>} The saved groups, in no particular order; none when the
|
|
176
|
+
* profile has no store yet.
|
|
177
|
+
* @throws {Error} When the store has compacted tables this doesn't read.
|
|
178
|
+
* @example
|
|
179
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
180
|
+
* const { worker, close } = await profile.launch();
|
|
181
|
+
* await worker.evaluate(async () => chrome.tabGroups.update(await chrome.tabs.group({tabIds: [tabId]}), {title: 'Research'}));
|
|
182
|
+
* await close();
|
|
183
|
+
* const groups = await savedTabGroups(profile.dir); // → [{title: 'Research', pinned: true, urls: [...]}]
|
|
184
|
+
*/
|
|
185
|
+
export async function savedTabGroups(profile) {
|
|
186
|
+
const dir = path.join(profile, 'Default', 'Sync Data', 'LevelDB');
|
|
187
|
+
const files = await readdir(dir).catch(() => /** @type {string[]} */ ([]));
|
|
188
|
+
if (files.some((file) => file.endsWith('.ldb'))) throw new Error(`${dir} has compacted tables; only logs are read`);
|
|
189
|
+
/** @type {Map<string, Buffer>} */
|
|
190
|
+
const live = new Map();
|
|
191
|
+
for (const file of files.filter((name) => name.endsWith('.log')).sort()) {
|
|
192
|
+
for (const batch of batches(await readFile(path.join(dir, file)))) apply(batch, live);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @type {Map<string, SavedTabGroup>} */
|
|
196
|
+
const groups = new Map();
|
|
197
|
+
/** @type {{group: string, position: number, url: string}[]} */
|
|
198
|
+
const tabs = [];
|
|
199
|
+
for (const [key, value] of live) {
|
|
200
|
+
if (!key.startsWith(KEY_PREFIX)) continue;
|
|
201
|
+
const specifics = bytesOf(fields(value), 2);
|
|
202
|
+
if (!specifics) continue;
|
|
203
|
+
const spec = fields(specifics);
|
|
204
|
+
const guid = bytesOf(spec, 1)?.toString('utf8') ?? '';
|
|
205
|
+
const group = bytesOf(spec, 4);
|
|
206
|
+
const tab = bytesOf(spec, 5);
|
|
207
|
+
if (group) {
|
|
208
|
+
const g = fields(group);
|
|
209
|
+
groups.set(guid, { title: bytesOf(g, 2)?.toString('utf8') ?? '', pinned: g.some((f) => f.field === 4), urls: [] });
|
|
210
|
+
} else if (tab) {
|
|
211
|
+
const t = fields(tab);
|
|
212
|
+
const position = t.find((f) => f.field === 2)?.value;
|
|
213
|
+
tabs.push({
|
|
214
|
+
group: bytesOf(t, 1)?.toString('utf8') ?? '',
|
|
215
|
+
position: typeof position === 'number' ? position : 0,
|
|
216
|
+
url: bytesOf(t, 3)?.toString('utf8') ?? '',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const tab of tabs.sort((a, b) => a.position - b.position)) groups.get(tab.group)?.urls.push(tab.url);
|
|
221
|
+
return [...groups.values()];
|
|
222
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
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
|
+
import type { Worker } from 'playwright';
|
|
12
|
+
export type Manifest = Record<string, unknown>;
|
|
13
|
+
/** @import { Worker } from 'playwright' */
|
|
14
|
+
/**
|
|
15
|
+
* A manifest, as `manifest.json` parses.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {Record<string, unknown>} Manifest
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The manifest less some permissions (from `permissions`, `optional_permissions`
|
|
21
|
+
* and `host_permissions`). Pure: `manifest` is left as it was.
|
|
22
|
+
*
|
|
23
|
+
* @param {Manifest} manifest - The manifest.
|
|
24
|
+
* @param {readonly string[]} names - The permissions (or host patterns) to leave out.
|
|
25
|
+
* @returns {Manifest} A copy without them.
|
|
26
|
+
* @example
|
|
27
|
+
* withoutPermissions({permissions: ['tabs', 'alarms']}, ['alarms']); // → {permissions: ['tabs']}
|
|
28
|
+
*/
|
|
29
|
+
export declare function withoutPermissions(manifest: Manifest, names: readonly string[]): Manifest;
|
|
30
|
+
/**
|
|
31
|
+
* Chrome's install warnings for a manifest, as this browser words them.
|
|
32
|
+
*
|
|
33
|
+
* @param {Worker} worker - A loaded extension's service worker (`launchExtension`'s `worker`).
|
|
34
|
+
* @param {Manifest} manifest - The manifest to ask about (it needn't be the loaded extension's).
|
|
35
|
+
* @returns {Promise<string[]>} The warnings, in Chrome's order.
|
|
36
|
+
* @example
|
|
37
|
+
* assert.deepEqual(await installWarnings(worker, manifest), ['Read your browsing history']);
|
|
38
|
+
*/
|
|
39
|
+
export declare function installWarnings(worker: Worker, manifest: Manifest): Promise<string[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Chrome's install warnings for a manifest, with and without some permissions,
|
|
42
|
+
* and the ones only the permissions bring (a warning they reword counts).
|
|
43
|
+
*
|
|
44
|
+
* @param {Worker} worker - A loaded extension's service worker.
|
|
45
|
+
* @param {Manifest} manifest - The manifest.
|
|
46
|
+
* @param {readonly string[]} names - The permissions in question.
|
|
47
|
+
* @returns {Promise<{warnings: string[], without: string[], added: string[]}>} The warnings with
|
|
48
|
+
* them, without them, and those in the first list only.
|
|
49
|
+
* @example
|
|
50
|
+
* const { warnings, added } = await warningsAddedBy(worker, manifest, ['alarms']);
|
|
51
|
+
* assert.deepEqual(added, []); // alarms asks the user nothing
|
|
52
|
+
*/
|
|
53
|
+
export declare function warningsAddedBy(worker: Worker, manifest: Manifest, names: readonly string[]): Promise<{
|
|
54
|
+
warnings: string[];
|
|
55
|
+
without: string[];
|
|
56
|
+
added: string[];
|
|
57
|
+
}>;
|
|
@@ -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
|
+
}
|
package/src/ui/menu.d.ts
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
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
|
+
* The page. Default `document`.
|
|
144
|
+
*/
|
|
145
|
+
doc?: Document;
|
|
146
|
+
};
|
|
147
|
+
export type ActionMenu = {
|
|
148
|
+
/**
|
|
149
|
+
* The `<dialog class="menu" role="menu">`, in the page's body.
|
|
150
|
+
*/
|
|
151
|
+
element: HTMLDialogElement;
|
|
152
|
+
/**
|
|
153
|
+
* Fill it with `entries`
|
|
154
|
+
* and open it (closing it first if it's open).
|
|
155
|
+
*/
|
|
156
|
+
open: (entries: readonly MenuEntry[], where: MenuPlace) => void;
|
|
157
|
+
/**
|
|
158
|
+
* Close it, if open, and return the focus.
|
|
159
|
+
*/
|
|
160
|
+
close: () => void;
|
|
161
|
+
};
|
|
162
|
+
/**
|
|
163
|
+
* What {@link createMenu} takes.
|
|
164
|
+
*
|
|
165
|
+
* @typedef {object} MenuOptions
|
|
166
|
+
* @property {string} [id] The menu element's id.
|
|
167
|
+
* @property {'over' | 'below'} [placement] Where it opens from a button. Default `'over'`, as Chrome's.
|
|
168
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
169
|
+
*/
|
|
170
|
+
/**
|
|
171
|
+
* A page's action menu.
|
|
172
|
+
*
|
|
173
|
+
* @typedef {object} ActionMenu
|
|
174
|
+
* @property {HTMLDialogElement} element The `<dialog class="menu" role="menu">`, in the page's body.
|
|
175
|
+
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
176
|
+
* and open it (closing it first if it's open).
|
|
177
|
+
* @property {() => void} close Close it, if open, and return the focus.
|
|
178
|
+
*/
|
|
179
|
+
/**
|
|
180
|
+
* Make a page's action menu: one `<dialog class="menu">` that any number of
|
|
181
|
+
* ⋮ buttons (or right-clicks) open with their own items.
|
|
182
|
+
*
|
|
183
|
+
* @param {MenuOptions} [options] - Its id, placement and page.
|
|
184
|
+
* @returns {ActionMenu} The menu.
|
|
185
|
+
* @example
|
|
186
|
+
* const menu = createMenu();
|
|
187
|
+
* row.append(moreActionsButton(workspace.name, {
|
|
188
|
+
* onClick: (button) => menu.open([
|
|
189
|
+
* {label: 'Rename', run: () => rename(workspace)},
|
|
190
|
+
* 'separator',
|
|
191
|
+
* {label: 'Delete', run: () => remove(workspace)},
|
|
192
|
+
* ], {anchor: button}),
|
|
193
|
+
* }));
|
|
194
|
+
*/
|
|
195
|
+
export declare function createMenu({ id, placement, doc }?: MenuOptions): ActionMenu;
|
|
196
|
+
export type MoreActionsOptions = {
|
|
197
|
+
/**
|
|
198
|
+
* The whole label, for a page that translates it. Default
|
|
199
|
+
* `More actions for “<name>”`, Chrome's words.
|
|
200
|
+
*/
|
|
201
|
+
label?: string;
|
|
202
|
+
/**
|
|
203
|
+
* More classes, after `icon-button`.
|
|
204
|
+
*/
|
|
205
|
+
className?: string;
|
|
206
|
+
/**
|
|
207
|
+
* In the tab order. Default true; false for a row that takes the
|
|
208
|
+
* focus itself and opens the menu from the keyboard.
|
|
209
|
+
*/
|
|
210
|
+
focusable?: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* Show the label on hover too (`title`). Default false.
|
|
213
|
+
*/
|
|
214
|
+
tooltip?: boolean;
|
|
215
|
+
/**
|
|
216
|
+
* Opens its menu.
|
|
217
|
+
*/
|
|
218
|
+
onClick?: (button: HTMLButtonElement, event: MouseEvent) => void;
|
|
219
|
+
/**
|
|
220
|
+
* The page. Default `document`.
|
|
221
|
+
*/
|
|
222
|
+
doc?: Document;
|
|
223
|
+
};
|
|
224
|
+
/**
|
|
225
|
+
* What {@link moreActionsButton} takes.
|
|
226
|
+
*
|
|
227
|
+
* @typedef {object} MoreActionsOptions
|
|
228
|
+
* @property {string} [label] The whole label, for a page that translates it. Default
|
|
229
|
+
* `More actions for “<name>”`, Chrome's words.
|
|
230
|
+
* @property {string} [className] More classes, after `icon-button`.
|
|
231
|
+
* @property {boolean} [focusable] In the tab order. Default true; false for a row that takes the
|
|
232
|
+
* focus itself and opens the menu from the keyboard.
|
|
233
|
+
* @property {boolean} [tooltip] Show the label on hover too (`title`). Default false.
|
|
234
|
+
* @property {(button: HTMLButtonElement, event: MouseEvent) => void} [onClick] Opens its menu.
|
|
235
|
+
* @property {Document} [doc] The page. Default `document`.
|
|
236
|
+
*/
|
|
237
|
+
/**
|
|
238
|
+
* A row's ⋮ button: a 32px cr-icon-button with cr:more-vert (the
|
|
239
|
+
* stylesheet's `--icon-more-vert`, so the page needs no sprite), labelled for
|
|
240
|
+
* screen readers as Chrome labels its own.
|
|
241
|
+
*
|
|
242
|
+
* @param {string} name - What the row is ("Research"), for "More actions for “Research”".
|
|
243
|
+
* @param {MoreActionsOptions} [options] - Its label, classes, tab stop, tooltip and click.
|
|
244
|
+
* @returns {HTMLButtonElement} The button.
|
|
245
|
+
* @example
|
|
246
|
+
* row.append(moreActionsButton(site, {onClick: (button) => menu.open(siteItems(site), {anchor: button})}));
|
|
247
|
+
* // A translated label:
|
|
248
|
+
* moreActionsButton(name, {label: t('rowActions', name), className: 'row-menu', focusable: false});
|
|
249
|
+
*/
|
|
250
|
+
export declare function moreActionsButton(name: string, { label, className, focusable, tooltip, onClick, doc }?: MoreActionsOptions): HTMLButtonElement;
|