@arraypress/extension-kit 1.3.0 → 1.5.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 +86 -19
- package/package.json +1 -1
- package/src/build/vendor.js +1 -1
- package/src/dom/i18n.d.ts +11 -3
- package/src/dom/i18n.js +17 -4
- package/src/dom/setup.d.ts +4 -3
- package/src/dom/setup.js +3 -2
- package/src/index.d.ts +8 -3
- package/src/index.js +8 -3
- package/src/sites/sites.d.ts +33 -1
- package/src/sites/sites.js +42 -1
- package/src/testing/browser.js +1 -1
- package/src/testing/e2e.js +1 -1
- package/src/testing/index.d.ts +0 -1
- package/src/testing/index.js +0 -1
- package/src/{testing → time}/time.d.ts +7 -6
- package/src/{testing → time}/time.js +7 -6
- package/src/ui/site-list.d.ts +23 -6
- package/src/ui/site-list.js +46 -6
- package/src/ui/text-dialog.d.ts +107 -37
- package/src/ui/text-dialog.js +167 -72
package/src/ui/text-dialog.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Chrome's
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
2
|
+
* Chrome's text dialog (cr-dialog with cr-inputs): a title, labelled fields,
|
|
3
|
+
* Cancel and an action button. One field, as chrome://bookmarks asks for a
|
|
4
|
+
* folder's name (`askText`); several, as chrome://settings/searchEngines asks
|
|
5
|
+
* for a site search (`askFields`). The action button stays disabled while a
|
|
6
|
+
* field is blank or isn't valid, with the reason under that field. Enter
|
|
7
|
+
* means the action button, Esc and Cancel cancel, and the focus goes back to
|
|
8
|
+
* what opened it. The page needs no markup: the dialog (`#text-dialog`) is
|
|
9
|
+
* made the first time, its fields each time it's asked.
|
|
8
10
|
*
|
|
9
11
|
* @module ui/text-dialog
|
|
10
12
|
*/
|
|
@@ -12,23 +14,43 @@
|
|
|
12
14
|
import { focusedElement } from '../dom/focus.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
15
|
-
*
|
|
17
|
+
* One field of the dialog.
|
|
16
18
|
*
|
|
17
|
-
* @typedef {object}
|
|
19
|
+
* @typedef {object} FieldRequest
|
|
20
|
+
* @property {string} name Its key in the answer ("folder"), and part of its id.
|
|
21
|
+
* @property {string} label Its label ("Folder").
|
|
22
|
+
* @property {string} [value] The text to start with. Default ''.
|
|
23
|
+
* @property {string} [placeholder] Shown while it's empty. Default none.
|
|
24
|
+
* @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
|
|
25
|
+
* shown under the field; null when it can. Default: anything but blank.
|
|
26
|
+
* @property {number} [maxLength] Its `maxlength`. Default none.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What the dialog asks, whatever its fields.
|
|
31
|
+
*
|
|
32
|
+
* @typedef {object} DialogRequest
|
|
18
33
|
* @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
34
|
* @property {string} [action] The action button's text. Default "Save".
|
|
22
35
|
* @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
36
|
* @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
|
|
27
37
|
* goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
|
|
28
38
|
* Default what had the focus when it opened.
|
|
29
39
|
* @property {Document} [doc] The page. Default `document`.
|
|
30
40
|
*/
|
|
31
41
|
|
|
42
|
+
/**
|
|
43
|
+
* What the one-field dialog asks.
|
|
44
|
+
*
|
|
45
|
+
* @typedef {DialogRequest & Omit<FieldRequest, 'name'>} TextRequest
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What the dialog with several fields asks.
|
|
50
|
+
*
|
|
51
|
+
* @typedef {DialogRequest & {fields: FieldRequest[]}} FieldsRequest
|
|
52
|
+
*/
|
|
53
|
+
|
|
32
54
|
/**
|
|
33
55
|
* Whether a text can be used, and the reason to show when it can't. Pure.
|
|
34
56
|
*
|
|
@@ -47,22 +69,60 @@ export function checkText(text, validate = () => null) {
|
|
|
47
69
|
}
|
|
48
70
|
|
|
49
71
|
/**
|
|
50
|
-
*
|
|
72
|
+
* A field's element ids. A one-field dialog's are `text-dialog-input` and `text-dialog-error`;
|
|
73
|
+
* with several, each is named: `text-dialog-<name>` and `text-dialog-<name>-error`. Pure.
|
|
74
|
+
*
|
|
75
|
+
* @param {string} name - The field's name.
|
|
76
|
+
* @param {boolean} only - Whether it's the dialog's only field.
|
|
77
|
+
* @returns {{input: string, error: string}} The ids.
|
|
78
|
+
* @example
|
|
79
|
+
* fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
|
|
80
|
+
*/
|
|
81
|
+
export function fieldIds(name, only) {
|
|
82
|
+
return only
|
|
83
|
+
? { input: 'text-dialog-input', error: 'text-dialog-error' }
|
|
84
|
+
: { input: `text-dialog-${name}`, error: `text-dialog-${name}-error` };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The dialog's lasting parts; the fields go in `body` each time.
|
|
51
89
|
*
|
|
52
90
|
* @typedef {object} Parts
|
|
53
91
|
* @property {HTMLDialogElement} dialog The `<dialog>`.
|
|
54
92
|
* @property {HTMLFormElement} form Its form.
|
|
55
93
|
* @property {HTMLElement} title The title.
|
|
56
|
-
* @property {
|
|
57
|
-
* @property {HTMLInputElement} input The field.
|
|
58
|
-
* @property {HTMLElement} error The reason under the field.
|
|
94
|
+
* @property {HTMLElement} body Where the fields go.
|
|
59
95
|
* @property {HTMLButtonElement} cancel The cancel button.
|
|
60
96
|
* @property {HTMLButtonElement} confirm The action button.
|
|
61
97
|
*/
|
|
62
98
|
|
|
99
|
+
/**
|
|
100
|
+
* A field as shown.
|
|
101
|
+
*
|
|
102
|
+
* @typedef {object} Field
|
|
103
|
+
* @property {FieldRequest} request What was asked.
|
|
104
|
+
* @property {HTMLInputElement} input Its input.
|
|
105
|
+
* @property {HTMLElement} error The reason under it.
|
|
106
|
+
*/
|
|
107
|
+
|
|
63
108
|
/** Each page's dialog, made on first use. @type {WeakMap<Document, Parts>} */
|
|
64
109
|
const made = new WeakMap();
|
|
65
110
|
|
|
111
|
+
/**
|
|
112
|
+
* An element of `doc` with `attributes`.
|
|
113
|
+
*
|
|
114
|
+
* @template {keyof HTMLElementTagNameMap} K
|
|
115
|
+
* @param {Document} doc - The page.
|
|
116
|
+
* @param {K} tag - Its tag.
|
|
117
|
+
* @param {Record<string, string>} [attributes] - Its attributes.
|
|
118
|
+
* @returns {HTMLElementTagNameMap[K]} The element.
|
|
119
|
+
*/
|
|
120
|
+
function element(doc, tag, attributes = {}) {
|
|
121
|
+
const node = doc.createElement(tag);
|
|
122
|
+
for (const [name, value] of Object.entries(attributes)) node.setAttribute(name, value);
|
|
123
|
+
return node;
|
|
124
|
+
}
|
|
125
|
+
|
|
66
126
|
/**
|
|
67
127
|
* The page's dialog, made the first time.
|
|
68
128
|
*
|
|
@@ -72,90 +132,98 @@ const made = new WeakMap();
|
|
|
72
132
|
function partsFor(doc) {
|
|
73
133
|
const existing = made.get(doc);
|
|
74
134
|
if (existing) return existing;
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
const
|
|
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);
|
|
135
|
+
const dialog = element(doc, 'dialog', { id: 'text-dialog', class: 'dialog', 'aria-labelledby': 'text-dialog-title' });
|
|
136
|
+
const form = element(doc, 'form', { novalidate: '' });
|
|
137
|
+
const title = element(doc, 'h2', { id: 'text-dialog-title', class: 'dialog-title' });
|
|
138
|
+
const body = element(doc, 'div', { class: 'dialog-body' });
|
|
139
|
+
const buttons = element(doc, 'div', { class: 'dialog-buttons' });
|
|
140
|
+
const cancel = element(doc, 'button', { type: 'button', class: 'cancel' });
|
|
141
|
+
const confirm = element(doc, 'button', { id: 'text-dialog-confirm', type: 'submit', class: 'action' });
|
|
100
142
|
buttons.append(cancel, confirm);
|
|
101
143
|
form.append(title, body, buttons);
|
|
102
144
|
dialog.append(form);
|
|
103
145
|
doc.body.append(dialog);
|
|
104
|
-
const parts = { dialog, form, title,
|
|
146
|
+
const parts = { dialog, form, title, body, cancel, confirm };
|
|
105
147
|
made.set(doc, parts);
|
|
106
148
|
return parts;
|
|
107
149
|
}
|
|
108
150
|
|
|
109
|
-
/**
|
|
151
|
+
/**
|
|
152
|
+
* A field's label, input and reason, as cr-input lays them out.
|
|
153
|
+
*
|
|
154
|
+
* @param {Document} doc - The page.
|
|
155
|
+
* @param {FieldRequest} request - The field.
|
|
156
|
+
* @param {boolean} only - Whether it's the dialog's only field.
|
|
157
|
+
* @returns {{row: HTMLElement, field: Field}} Its markup, and the parts to read.
|
|
158
|
+
*/
|
|
159
|
+
function fieldFor(doc, request, only) {
|
|
160
|
+
const ids = fieldIds(request.name, only);
|
|
161
|
+
const row = element(doc, 'div', { class: 'field' });
|
|
162
|
+
const label = element(doc, 'label', { for: ids.input });
|
|
163
|
+
label.textContent = request.label;
|
|
164
|
+
const input = element(doc, 'input', { id: ids.input, type: 'text', autocomplete: 'off', 'aria-describedby': ids.error });
|
|
165
|
+
input.value = request.value ?? '';
|
|
166
|
+
if (request.placeholder) input.placeholder = request.placeholder;
|
|
167
|
+
if (request.maxLength !== undefined) input.maxLength = request.maxLength;
|
|
168
|
+
const error = element(doc, 'span', { id: ids.error, class: 'hint error' });
|
|
169
|
+
error.hidden = true;
|
|
170
|
+
row.append(label, input, error);
|
|
171
|
+
return { row, field: { request, input, error } };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The open dialog's settle function, per page. @type {WeakMap<Document, (answer: Record<string, string> | null) => void>} */
|
|
110
175
|
const pending = new WeakMap();
|
|
111
176
|
|
|
112
177
|
/**
|
|
113
|
-
* Ask for
|
|
178
|
+
* Ask for several pieces of text in the page's dialog, one field each.
|
|
114
179
|
*
|
|
115
|
-
* @param {
|
|
116
|
-
* @returns {Promise<string | null>}
|
|
180
|
+
* @param {FieldsRequest} request - What to ask.
|
|
181
|
+
* @returns {Promise<Record<string, string> | null>} Each field's text by its name, trimmed; null
|
|
182
|
+
* when cancelled.
|
|
117
183
|
* @example
|
|
118
|
-
* const
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
184
|
+
* const rule = await askFields({
|
|
185
|
+
* title: 'Add folder rule', action: 'Add',
|
|
186
|
+
* fields: [
|
|
187
|
+
* {name: 'from', label: 'Downloads from', placeholder: 'example.com or .pdf', validate: checkFrom},
|
|
188
|
+
* {name: 'folder', label: 'Folder', placeholder: 'Receipts'},
|
|
189
|
+
* ],
|
|
124
190
|
* });
|
|
191
|
+
* if (rule) addRule(rule.from, rule.folder);
|
|
125
192
|
*/
|
|
126
|
-
export function
|
|
127
|
-
title,
|
|
193
|
+
export function askFields({
|
|
194
|
+
title, fields: requests, action = 'Save', cancel = 'Cancel', returnTo, doc = document,
|
|
128
195
|
}) {
|
|
129
196
|
const parts = partsFor(doc);
|
|
130
197
|
pending.get(doc)?.(null);
|
|
131
198
|
const active = focusedElement(doc);
|
|
132
199
|
const back = returnTo === undefined ? (active instanceof /** @type {Window & typeof globalThis} */ (doc.defaultView).HTMLElement ? active : null) : returnTo;
|
|
133
200
|
|
|
201
|
+
const built = requests.map((request) => fieldFor(doc, request, requests.length === 1));
|
|
202
|
+
const fields = built.map(({ field }) => field);
|
|
203
|
+
parts.body.replaceChildren(...built.map(({ row }) => row));
|
|
134
204
|
parts.title.textContent = title;
|
|
135
|
-
parts.label.textContent = label;
|
|
136
205
|
parts.confirm.textContent = action;
|
|
137
206
|
parts.cancel.textContent = cancel;
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
parts.error.hidden = !reason;
|
|
146
|
-
parts.confirm.disabled = !ok;
|
|
207
|
+
|
|
208
|
+
const allOk = () => fields.every(({ input, request }) => checkText(input.value, request.validate).ok);
|
|
209
|
+
/** @param {Field} field */
|
|
210
|
+
const refresh = ({ input, error, request }) => {
|
|
211
|
+
const { reason } = checkText(input.value, request.validate);
|
|
212
|
+
error.textContent = reason;
|
|
213
|
+
error.hidden = !reason;
|
|
147
214
|
};
|
|
148
|
-
refresh();
|
|
215
|
+
for (const field of fields) refresh(field);
|
|
216
|
+
parts.confirm.disabled = !allOk();
|
|
149
217
|
|
|
150
218
|
return new Promise((resolve) => {
|
|
151
219
|
const controller = new AbortController();
|
|
152
220
|
const { signal } = controller;
|
|
153
|
-
/** @param {string | null}
|
|
154
|
-
const settle = (
|
|
221
|
+
/** @param {Record<string, string> | null} answer */
|
|
222
|
+
const settle = (answer) => {
|
|
155
223
|
controller.abort();
|
|
156
224
|
pending.delete(doc);
|
|
157
225
|
if (parts.dialog.open) parts.dialog.close();
|
|
158
|
-
resolve(
|
|
226
|
+
resolve(answer);
|
|
159
227
|
// After whatever the answer re-renders, unless another dialog opened meanwhile.
|
|
160
228
|
queueMicrotask(() => {
|
|
161
229
|
if (doc.querySelector('dialog[open]')) return;
|
|
@@ -164,11 +232,16 @@ export function askText({
|
|
|
164
232
|
});
|
|
165
233
|
};
|
|
166
234
|
pending.set(doc, settle);
|
|
167
|
-
|
|
235
|
+
for (const field of fields) {
|
|
236
|
+
field.input.addEventListener('input', () => {
|
|
237
|
+
refresh(field);
|
|
238
|
+
parts.confirm.disabled = !allOk();
|
|
239
|
+
}, { signal });
|
|
240
|
+
}
|
|
168
241
|
// Enter submits through the action button (the form's only submit button), never while it's disabled.
|
|
169
242
|
parts.form.addEventListener('submit', (event) => {
|
|
170
243
|
event.preventDefault();
|
|
171
|
-
if (
|
|
244
|
+
if (allOk()) settle(Object.fromEntries(fields.map(({ request, input }) => [request.name, input.value.trim()])));
|
|
172
245
|
}, { signal });
|
|
173
246
|
parts.cancel.addEventListener('click', () => settle(null), { signal });
|
|
174
247
|
parts.dialog.addEventListener('keydown', (event) => {
|
|
@@ -182,6 +255,28 @@ export function askText({
|
|
|
182
255
|
}, { signal });
|
|
183
256
|
|
|
184
257
|
parts.dialog.showModal();
|
|
185
|
-
|
|
258
|
+
// The first field still to fill in, or the first, its text selected to type over.
|
|
259
|
+
(fields.find(({ input }) => !input.value) ?? fields[0])?.input.select();
|
|
186
260
|
});
|
|
187
261
|
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Ask for a piece of text in the page's dialog, with one field.
|
|
265
|
+
*
|
|
266
|
+
* @param {TextRequest} request - What to ask.
|
|
267
|
+
* @returns {Promise<string | null>} The text, trimmed; null when cancelled.
|
|
268
|
+
* @example
|
|
269
|
+
* const name = await askText({title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200});
|
|
270
|
+
* if (name) rename(workspace, name);
|
|
271
|
+
*
|
|
272
|
+
* const site = await askText({
|
|
273
|
+
* title: 'Add site', label: 'Site', action: 'Add',
|
|
274
|
+
* validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
|
|
275
|
+
* });
|
|
276
|
+
*/
|
|
277
|
+
export async function askText({
|
|
278
|
+
label, value, placeholder, validate, maxLength, ...dialog
|
|
279
|
+
}) {
|
|
280
|
+
const answer = await askFields({ ...dialog, fields: [{ name: 'input', label, value, placeholder, validate, maxLength }] });
|
|
281
|
+
return answer?.input ?? null;
|
|
282
|
+
}
|