@arraypress/extension-kit 1.4.0 → 1.6.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.
@@ -1,10 +1,12 @@
1
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.
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
- * What the dialog asks.
17
+ * One field of the dialog.
16
18
  *
17
- * @typedef {object} TextRequest
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
- * The dialog's parts.
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 {HTMLLabelElement} label The field's label.
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
- * @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);
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, label, input, error, cancel, confirm };
146
+ const parts = { dialog, form, title, body, cancel, confirm };
105
147
  made.set(doc, parts);
106
148
  return parts;
107
149
  }
108
150
 
109
- /** The open dialog's settle function, per page. @type {WeakMap<Document, (text: string | null) => void>} */
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 a piece of text in the page's one-field dialog.
178
+ * Ask for several pieces of text in the page's dialog, one field each.
114
179
  *
115
- * @param {TextRequest} request - What to ask.
116
- * @returns {Promise<string | null>} The text, trimmed; null when cancelled.
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 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'),
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 askText({
127
- title, label, value = '', action = 'Save', cancel = 'Cancel', validate, maxLength, returnTo, doc = document,
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
- 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;
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} text */
154
- const settle = (text) => {
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(text);
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
- parts.input.addEventListener('input', refresh, { signal });
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 (checkText(parts.input.value, validate).ok) settle(parts.input.value.trim());
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
- parts.input.select();
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
+ }