@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,187 @@
1
+ /**
2
+ * Chrome's one-field dialog (cr-dialog with a cr-input), as chrome://bookmarks
3
+ * asks for a folder's name: a title, a labelled field, Cancel and an action
4
+ * button. The action button stays disabled while the text is blank or isn't
5
+ * valid, with the reason under the field. Enter means the action button, Esc
6
+ * and Cancel cancel, and the focus goes back to what opened it. The page
7
+ * needs no markup: the dialog (`#text-dialog`) is made the first time.
8
+ *
9
+ * @module ui/text-dialog
10
+ */
11
+
12
+ import { focusedElement } from '../dom/focus.js';
13
+
14
+ /**
15
+ * What the dialog asks.
16
+ *
17
+ * @typedef {object} TextRequest
18
+ * @property {string} title The dialog's title ("Rename folder").
19
+ * @property {string} label The field's label ("Name").
20
+ * @property {string} [value] The text to start with (selected). Default ''.
21
+ * @property {string} [action] The action button's text. Default "Save".
22
+ * @property {string} [cancel] The cancel button's text. Default "Cancel".
23
+ * @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
24
+ * shown under the field; null when it can. Default: anything but blank.
25
+ * @property {number} [maxLength] The field's `maxlength`. Default none.
26
+ * @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
27
+ * goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
28
+ * Default what had the focus when it opened.
29
+ * @property {Document} [doc] The page. Default `document`.
30
+ */
31
+
32
+ /**
33
+ * Whether a text can be used, and the reason to show when it can't. Pure.
34
+ *
35
+ * @param {string} text - What's typed.
36
+ * @param {(text: string) => string | null} [validate] - Why a non-blank text can't be used, or null.
37
+ * @returns {{ok: boolean, reason: string}} `ok` when it can be used; `reason` is '' for blank text
38
+ * (nothing to say yet) and for a usable one.
39
+ * @example
40
+ * checkText(' '); // → {ok: false, reason: ''}
41
+ * checkText('x', () => 'Enter a site'); // → {ok: false, reason: 'Enter a site'}
42
+ */
43
+ export function checkText(text, validate = () => null) {
44
+ if (!text.trim()) return { ok: false, reason: '' };
45
+ const reason = validate(text);
46
+ return { ok: reason === null, reason: reason ?? '' };
47
+ }
48
+
49
+ /**
50
+ * The dialog's parts.
51
+ *
52
+ * @typedef {object} Parts
53
+ * @property {HTMLDialogElement} dialog The `<dialog>`.
54
+ * @property {HTMLFormElement} form Its form.
55
+ * @property {HTMLElement} title The title.
56
+ * @property {HTMLLabelElement} label The field's label.
57
+ * @property {HTMLInputElement} input The field.
58
+ * @property {HTMLElement} error The reason under the field.
59
+ * @property {HTMLButtonElement} cancel The cancel button.
60
+ * @property {HTMLButtonElement} confirm The action button.
61
+ */
62
+
63
+ /** Each page's dialog, made on first use. @type {WeakMap<Document, Parts>} */
64
+ const made = new WeakMap();
65
+
66
+ /**
67
+ * The page's dialog, made the first time.
68
+ *
69
+ * @param {Document} doc - The page.
70
+ * @returns {Parts} Its parts.
71
+ */
72
+ function partsFor(doc) {
73
+ const existing = made.get(doc);
74
+ if (existing) return existing;
75
+ /**
76
+ * @template {keyof HTMLElementTagNameMap} K
77
+ * @param {K} tag
78
+ * @param {Record<string, string>} [attributes]
79
+ * @returns {HTMLElementTagNameMap[K]}
80
+ */
81
+ const el = (tag, attributes = {}) => {
82
+ const node = doc.createElement(tag);
83
+ for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
84
+ return node;
85
+ };
86
+ const dialog = el('dialog', { id: 'text-dialog', class: 'dialog', 'aria-labelledby': 'text-dialog-title' });
87
+ const form = el('form', { novalidate: '' });
88
+ const title = el('h2', { id: 'text-dialog-title', class: 'dialog-title' });
89
+ const body = el('div', { class: 'dialog-body' });
90
+ const field = el('div', { class: 'field' });
91
+ const label = el('label', { id: 'text-dialog-label', for: 'text-dialog-input' });
92
+ const input = el('input', { id: 'text-dialog-input', type: 'text', autocomplete: 'off', 'aria-describedby': 'text-dialog-error' });
93
+ const error = el('span', { id: 'text-dialog-error', class: 'hint error' });
94
+ error.hidden = true;
95
+ const buttons = el('div', { class: 'dialog-buttons' });
96
+ const cancel = el('button', { type: 'button', class: 'cancel' });
97
+ const confirm = el('button', { id: 'text-dialog-confirm', type: 'submit', class: 'action' });
98
+ field.append(label, input, error);
99
+ body.append(field);
100
+ buttons.append(cancel, confirm);
101
+ form.append(title, body, buttons);
102
+ dialog.append(form);
103
+ doc.body.append(dialog);
104
+ const parts = { dialog, form, title, label, input, error, cancel, confirm };
105
+ made.set(doc, parts);
106
+ return parts;
107
+ }
108
+
109
+ /** The open dialog's settle function, per page. @type {WeakMap<Document, (text: string | null) => void>} */
110
+ const pending = new WeakMap();
111
+
112
+ /**
113
+ * Ask for a piece of text in the page's one-field dialog.
114
+ *
115
+ * @param {TextRequest} request - What to ask.
116
+ * @returns {Promise<string | null>} The text, trimmed; null when cancelled.
117
+ * @example
118
+ * const name = await askText({title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200});
119
+ * if (name) rename(workspace, name);
120
+ *
121
+ * const site = await askText({
122
+ * title: 'Add site', label: 'Site', action: 'Add',
123
+ * validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
124
+ * });
125
+ */
126
+ export function askText({
127
+ title, label, value = '', action = 'Save', cancel = 'Cancel', validate, maxLength, returnTo, doc = document,
128
+ }) {
129
+ const parts = partsFor(doc);
130
+ pending.get(doc)?.(null);
131
+ const active = focusedElement(doc);
132
+ const back = returnTo === undefined ? (active instanceof /** @type {Window & typeof globalThis} */ (doc.defaultView).HTMLElement ? active : null) : returnTo;
133
+
134
+ parts.title.textContent = title;
135
+ parts.label.textContent = label;
136
+ parts.confirm.textContent = action;
137
+ parts.cancel.textContent = cancel;
138
+ parts.input.value = value;
139
+ if (maxLength === undefined) parts.input.removeAttribute('maxlength');
140
+ else parts.input.maxLength = maxLength;
141
+
142
+ const refresh = () => {
143
+ const { ok, reason } = checkText(parts.input.value, validate);
144
+ parts.error.textContent = reason;
145
+ parts.error.hidden = !reason;
146
+ parts.confirm.disabled = !ok;
147
+ };
148
+ refresh();
149
+
150
+ return new Promise((resolve) => {
151
+ const controller = new AbortController();
152
+ const { signal } = controller;
153
+ /** @param {string | null} text */
154
+ const settle = (text) => {
155
+ controller.abort();
156
+ pending.delete(doc);
157
+ if (parts.dialog.open) parts.dialog.close();
158
+ resolve(text);
159
+ // After whatever the answer re-renders, unless another dialog opened meanwhile.
160
+ queueMicrotask(() => {
161
+ if (doc.querySelector('dialog[open]')) return;
162
+ const target = typeof back === 'function' ? back() : back;
163
+ if (target?.isConnected) target.focus();
164
+ });
165
+ };
166
+ pending.set(doc, settle);
167
+ parts.input.addEventListener('input', refresh, { signal });
168
+ // Enter submits through the action button (the form's only submit button), never while it's disabled.
169
+ parts.form.addEventListener('submit', (event) => {
170
+ event.preventDefault();
171
+ if (checkText(parts.input.value, validate).ok) settle(parts.input.value.trim());
172
+ }, { signal });
173
+ parts.cancel.addEventListener('click', () => settle(null), { signal });
174
+ parts.dialog.addEventListener('keydown', (event) => {
175
+ if (event.key !== 'Escape') return;
176
+ event.preventDefault();
177
+ settle(null);
178
+ }, { signal });
179
+ parts.dialog.addEventListener('cancel', (event) => {
180
+ event.preventDefault();
181
+ settle(null);
182
+ }, { signal });
183
+
184
+ parts.dialog.showModal();
185
+ parts.input.select();
186
+ });
187
+ }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Chrome's toast (cr-toast), bottom left, as chrome://bookmarks shows one
3
+ * after a delete: "Deleted “Research” Undo". One per page, made by
4
+ * {@link createToast}; a new message replaces the last and restarts the wait.
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
+ *
10
+ * @module ui/toast
11
+ */
12
+ /** How long chrome://bookmarks shows a toast (its cr-toast-manager's `duration`), in ms. */
13
+ export declare const TOAST_MS = 10000;
14
+ export type ToastOptions = {
15
+ /**
16
+ * The undo button's text. Default "Undo".
17
+ */
18
+ undoLabel?: string;
19
+ /**
20
+ * How long a toast stays, in ms. Default {@link TOAST_MS}.
21
+ */
22
+ duration?: number;
23
+ /**
24
+ * How long one with Undo stays. Default `duration`.
25
+ */
26
+ undoDuration?: number;
27
+ /**
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`.
34
+ */
35
+ doc?: Document;
36
+ };
37
+ export type Toast = {
38
+ /**
39
+ * The `#toast` element (`role="status"`, `popover="manual"`), in
40
+ * `root`; `:popover-open` while it shows.
41
+ */
42
+ element: HTMLElement;
43
+ /**
44
+ * Show `text`, with an Undo
45
+ * button that hides the toast and runs `undo` when one is given.
46
+ */
47
+ show: (text: string, undo?: (() => unknown) | null) => void;
48
+ /**
49
+ * Take it down (and its Undo with it).
50
+ */
51
+ hide: () => void;
52
+ };
53
+ /**
54
+ * What {@link createToast} takes.
55
+ *
56
+ * @typedef {object} ToastOptions
57
+ * @property {string} [undoLabel] The undo button's text. Default "Undo".
58
+ * @property {number} [duration] How long a toast stays, in ms. Default {@link TOAST_MS}.
59
+ * @property {number} [undoDuration] How long one with Undo stays. Default `duration`.
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`.
63
+ */
64
+ /**
65
+ * A page's toast.
66
+ *
67
+ * @typedef {object} Toast
68
+ * @property {HTMLElement} element The `#toast` element (`role="status"`, `popover="manual"`), in
69
+ * `root`; `:popover-open` while it shows.
70
+ * @property {(text: string, undo?: (() => unknown) | null) => void} show Show `text`, with an Undo
71
+ * button that hides the toast and runs `undo` when one is given.
72
+ * @property {() => void} hide Take it down (and its Undo with it).
73
+ */
74
+ /**
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.
79
+ *
80
+ * @param {ToastOptions} [options] - The Undo text, how long it stays, and where it goes.
81
+ * @returns {Toast} The toast.
82
+ * @example
83
+ * const toast = createToast();
84
+ * await deleteWorkspace(workspace);
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 });
88
+ */
89
+ export declare function createToast({ undoLabel, duration, undoDuration, root, doc }?: ToastOptions): Toast;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Chrome's toast (cr-toast), bottom left, as chrome://bookmarks shows one
3
+ * after a delete: "Deleted “Research” Undo". One per page, made by
4
+ * {@link createToast}; a new message replaces the last and restarts the wait.
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
+ *
10
+ * @module ui/toast
11
+ */
12
+
13
+ import { rootDocument } from '../dom/focus.js';
14
+
15
+ /** How long chrome://bookmarks shows a toast (its cr-toast-manager's `duration`), in ms. */
16
+ export const TOAST_MS = 10_000;
17
+
18
+ /**
19
+ * What {@link createToast} takes.
20
+ *
21
+ * @typedef {object} ToastOptions
22
+ * @property {string} [undoLabel] The undo button's text. Default "Undo".
23
+ * @property {number} [duration] How long a toast stays, in ms. Default {@link TOAST_MS}.
24
+ * @property {number} [undoDuration] How long one with Undo stays. Default `duration`.
25
+ * @property {ParentNode} [root] Where the toast goes: an element, or a shadow root (a content
26
+ * script's). Default the page's `body`.
27
+ * @property {Document} [doc] The page. Default `root`'s document, else `document`.
28
+ */
29
+
30
+ /**
31
+ * A page's toast.
32
+ *
33
+ * @typedef {object} Toast
34
+ * @property {HTMLElement} element The `#toast` element (`role="status"`, `popover="manual"`), in
35
+ * `root`; `:popover-open` while it shows.
36
+ * @property {(text: string, undo?: (() => unknown) | null) => void} show Show `text`, with an Undo
37
+ * button that hides the toast and runs `undo` when one is given.
38
+ * @property {() => void} hide Take it down (and its Undo with it).
39
+ */
40
+
41
+ /**
42
+ * Make the page's toast: `<div id="toast" class="toast" role="status"
43
+ * popover="manual">` with `#toast-text` and an Undo button `#toast-undo`,
44
+ * closed until shown. Make it when the page loads, so screen readers know the
45
+ * live region before it speaks.
46
+ *
47
+ * @param {ToastOptions} [options] - The Undo text, how long it stays, and where it goes.
48
+ * @returns {Toast} The toast.
49
+ * @example
50
+ * const toast = createToast();
51
+ * await deleteWorkspace(workspace);
52
+ * toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
53
+ * // In a content script's shadow root:
54
+ * createToast({ root: host.attachShadow({ mode: 'open' }), duration: 4000 });
55
+ */
56
+ export function createToast({ undoLabel = 'Undo', duration = TOAST_MS, undoDuration = duration, root, doc = rootDocument(root) } = {}) {
57
+ const toast = doc.createElement('div');
58
+ toast.id = 'toast';
59
+ toast.className = 'toast';
60
+ toast.setAttribute('role', 'status');
61
+ toast.setAttribute('aria-live', 'polite');
62
+ toast.popover = 'manual';
63
+ const text = doc.createElement('span');
64
+ text.id = 'toast-text';
65
+ const undoButton = doc.createElement('button');
66
+ undoButton.id = 'toast-undo';
67
+ undoButton.type = 'button';
68
+ undoButton.textContent = undoLabel;
69
+ undoButton.hidden = true;
70
+ toast.append(text, undoButton);
71
+ (root ?? doc.body).append(toast);
72
+
73
+ /** @type {ReturnType<typeof setTimeout> | undefined} */
74
+ let timer;
75
+ /** @type {(() => unknown) | null} */
76
+ let undoLast = null;
77
+
78
+ const hide = () => {
79
+ clearTimeout(timer);
80
+ toast.togglePopover(false);
81
+ undoLast = null;
82
+ };
83
+ undoButton.addEventListener('click', () => {
84
+ const undo = undoLast;
85
+ hide();
86
+ undo?.();
87
+ });
88
+
89
+ return {
90
+ element: toast,
91
+ show(message, undo = null) {
92
+ clearTimeout(timer);
93
+ text.textContent = message;
94
+ undoButton.hidden = !undo;
95
+ undoLast = undo;
96
+ // Hidden and shown again, so it's the newest in the top layer (over a popover since).
97
+ toast.togglePopover(false);
98
+ toast.togglePopover(true);
99
+ timer = setTimeout(hide, undo ? undoDuration : duration);
100
+ },
101
+ hide,
102
+ };
103
+ }