@arraypress/extension-kit 1.4.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 CHANGED
@@ -10,8 +10,8 @@ The pieces ArrayPress's Chrome extensions share, so each is written once:
10
10
  service worker shows the matching icon.
11
11
  - **About links**: README, Issues and PRIVACY.md in the extension's GitHub repository, plus
12
12
  package-time warnings for placeholder links.
13
- - **Chrome's page components**: the ⋮ action menu and its row button, the one-field dialog
14
- ("Rename folder"), the toast with Undo and a settings card's site list, built on the
13
+ - **Chrome's page components**: the ⋮ action menu and its row button, the text dialog
14
+ ("Rename folder", or several fields as "Add site search"), the toast with Undo and a settings card's site list, built on the
15
15
  stylesheet's classes; they work in a content script's shadow root too.
16
16
  - **Sites**: what people type into a per-site list, and whether a page is on one.
17
17
  - **Build and test helpers** (Node): icon rendering, vendoring npm packages (with a staleness
@@ -238,7 +238,7 @@ opens it below the button instead. `menuPosition` and `isInside` are the pure ge
238
238
  `tooltip` (a `title`) and `focusable: false` (out of the tab order, for a row that takes the focus)
239
239
  cover the rest.
240
240
 
241
- ### One-field dialog (cr-dialog)
241
+ ### Text dialog (cr-dialog)
242
242
 
243
243
  ```js
244
244
  const name = await askText({ title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200 });
@@ -254,6 +254,26 @@ disabled while the text is blank or `validate` gives a reason, which shows under
254
254
  focus goes back to what had it, or to `returnTo` (an element, or a function asked at the end).
255
255
  One `#text-dialog` per page, made on first use.
256
256
 
257
+ ### Dialog with several fields
258
+
259
+ ```js
260
+ const rule = await askFields({
261
+ title: 'Add folder rule', action: 'Add',
262
+ fields: [
263
+ { name: 'from', label: 'Downloads from', placeholder: 'example.com or .pdf', validate: checkFrom },
264
+ { name: 'folder', label: 'Folder', value: 'Receipts' },
265
+ ],
266
+ });
267
+ if (rule) addRule(rule.from, rule.folder);
268
+ ```
269
+
270
+ The same dialog as chrome://settings/searchEngines asks for a site search: a field each, in
271
+ order. Resolves to each field's text by its `name`, trimmed, or `null` when cancelled. The action
272
+ button stays disabled until every field is filled in and valid, each field's reason under it; the
273
+ focus starts in the first empty field. `askText` is this with one field. A one-field dialog's ids
274
+ are `text-dialog-input` and `text-dialog-error`; with several, `text-dialog-<name>` and
275
+ `text-dialog-<name>-error` (`fieldIds`), for a test to find them.
276
+
257
277
  ### Toast with Undo (cr-toast)
258
278
 
259
279
  ```js
@@ -387,7 +407,7 @@ except those listed, each with a reason:
387
407
  | Manager layout | `.manager`/`.layout`, `.sidebar` with `.tree` > `.tree-item`, `.main` with `.list-card` |
388
408
  | Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` (or `createMenu`) |
389
409
  | ⋮ icon | `<span class="icon icon-more-vert" aria-hidden="true">` (or `moreActionsButton`) |
390
- | Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` (one field: `askText`) |
410
+ | Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` (`askText`, `askFields`) |
391
411
  | Toast (cr-toast) | `<div class="toast" popover="manual">text <button>Undo</button></div>` (or `createToast`); with `hidden` instead of `popover`, it shows over the page but not the top layer |
392
412
  | Drawer (cr-drawer) | `<dialog class="drawer">` with `.drawer-head` and a `.nav` |
393
413
  | Side panel page | `<body class="side-panel">`: the panel's colours, a column 100vh high |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "Shared pieces for Chrome extensions: Chrome's own page styles, a Chrome Sync settings store, the light/dark toolbar glyph, About links, and build and test helpers.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
package/src/index.d.ts CHANGED
@@ -27,13 +27,15 @@ export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, scheme
27
27
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
28
28
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
29
29
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
30
- export { askText, checkText } from './ui/text-dialog.js';
30
+ export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
31
31
  export { TOAST_MS, createToast } from './ui/toast.js';
32
32
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
33
33
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
34
34
  export type MenuItem = import('./ui/menu.js').MenuItem;
35
35
  export type MenuPlace = import('./ui/menu.js').MenuPlace;
36
36
  export type SiteList = import('./ui/site-list.js').SiteList;
37
+ export type FieldRequest = import('./ui/text-dialog.js').FieldRequest;
38
+ export type FieldsRequest = import('./ui/text-dialog.js').FieldsRequest;
37
39
  export type TextRequest = import('./ui/text-dialog.js').TextRequest;
38
40
  export type Toast = import('./ui/toast.js').Toast;
39
41
  /**
@@ -44,6 +46,8 @@ export type Toast = import('./ui/toast.js').Toast;
44
46
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
45
47
  * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
46
48
  * @typedef {import('./ui/site-list.js').SiteList} SiteList
49
+ * @typedef {import('./ui/text-dialog.js').FieldRequest} FieldRequest
50
+ * @typedef {import('./ui/text-dialog.js').FieldsRequest} FieldsRequest
47
51
  * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
48
52
  * @typedef {import('./ui/toast.js').Toast} Toast
49
53
  */
package/src/index.js CHANGED
@@ -32,7 +32,7 @@ export {
32
32
  } from './toolbar/theme.js';
33
33
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
34
34
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
35
- export { askText, checkText } from './ui/text-dialog.js';
35
+ export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
36
36
  export { TOAST_MS, createToast } from './ui/toast.js';
37
37
 
38
38
  /**
@@ -43,6 +43,8 @@ export { TOAST_MS, createToast } from './ui/toast.js';
43
43
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
44
44
  * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
45
45
  * @typedef {import('./ui/site-list.js').SiteList} SiteList
46
+ * @typedef {import('./ui/text-dialog.js').FieldRequest} FieldRequest
47
+ * @typedef {import('./ui/text-dialog.js').FieldsRequest} FieldsRequest
46
48
  * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
47
49
  * @typedef {import('./ui/toast.js').Toast} Toast
48
50
  */
@@ -1,43 +1,55 @@
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
  */
11
- export type TextRequest = {
13
+ export type FieldRequest = {
12
14
  /**
13
- * The dialog's title ("Rename folder").
15
+ * Its key in the answer ("folder"), and part of its id.
14
16
  */
15
- title: string;
17
+ name: string;
16
18
  /**
17
- * The field's label ("Name").
19
+ * Its label ("Folder").
18
20
  */
19
21
  label: string;
20
22
  /**
21
- * The text to start with (selected). Default ''.
23
+ * The text to start with. Default ''.
22
24
  */
23
25
  value?: string;
24
26
  /**
25
- * The action button's text. Default "Save".
26
- */
27
- action?: string;
28
- /**
29
- * The cancel button's text. Default "Cancel".
27
+ * Shown while it's empty. Default none.
30
28
  */
31
- cancel?: string;
29
+ placeholder?: string;
32
30
  /**
33
31
  * Why the (non-blank) text can't be used,
34
32
  * shown under the field; null when it can. Default: anything but blank.
35
33
  */
36
34
  validate?: (text: string) => string | null;
37
35
  /**
38
- * The field's `maxlength`. Default none.
36
+ * Its `maxlength`. Default none.
39
37
  */
40
38
  maxLength?: number;
39
+ };
40
+ export type DialogRequest = {
41
+ /**
42
+ * The dialog's title ("Rename folder").
43
+ */
44
+ title: string;
45
+ /**
46
+ * The action button's text. Default "Save".
47
+ */
48
+ action?: string;
49
+ /**
50
+ * The cancel button's text. Default "Cancel".
51
+ */
52
+ cancel?: string;
41
53
  /**
42
54
  * Where the focus
43
55
  * goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
@@ -49,23 +61,44 @@ export type TextRequest = {
49
61
  */
50
62
  doc?: Document;
51
63
  };
64
+ export type TextRequest = DialogRequest & Omit<FieldRequest, 'name'>;
65
+ export type FieldsRequest = DialogRequest & {
66
+ fields: FieldRequest[];
67
+ };
52
68
  /**
53
- * What the dialog asks.
69
+ * One field of the dialog.
54
70
  *
55
- * @typedef {object} TextRequest
71
+ * @typedef {object} FieldRequest
72
+ * @property {string} name Its key in the answer ("folder"), and part of its id.
73
+ * @property {string} label Its label ("Folder").
74
+ * @property {string} [value] The text to start with. Default ''.
75
+ * @property {string} [placeholder] Shown while it's empty. Default none.
76
+ * @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
77
+ * shown under the field; null when it can. Default: anything but blank.
78
+ * @property {number} [maxLength] Its `maxlength`. Default none.
79
+ */
80
+ /**
81
+ * What the dialog asks, whatever its fields.
82
+ *
83
+ * @typedef {object} DialogRequest
56
84
  * @property {string} title The dialog's title ("Rename folder").
57
- * @property {string} label The field's label ("Name").
58
- * @property {string} [value] The text to start with (selected). Default ''.
59
85
  * @property {string} [action] The action button's text. Default "Save".
60
86
  * @property {string} [cancel] The cancel button's text. Default "Cancel".
61
- * @property {(text: string) => string | null} [validate] Why the (non-blank) text can't be used,
62
- * shown under the field; null when it can. Default: anything but blank.
63
- * @property {number} [maxLength] The field's `maxlength`. Default none.
64
87
  * @property {HTMLElement | (() => HTMLElement | null | undefined) | null} [returnTo] Where the focus
65
88
  * goes when it closes, or a function that says (asked then, so it can pick a row drawn since).
66
89
  * Default what had the focus when it opened.
67
90
  * @property {Document} [doc] The page. Default `document`.
68
91
  */
92
+ /**
93
+ * What the one-field dialog asks.
94
+ *
95
+ * @typedef {DialogRequest & Omit<FieldRequest, 'name'>} TextRequest
96
+ */
97
+ /**
98
+ * What the dialog with several fields asks.
99
+ *
100
+ * @typedef {DialogRequest & {fields: FieldRequest[]}} FieldsRequest
101
+ */
69
102
  /**
70
103
  * Whether a text can be used, and the reason to show when it can't. Pure.
71
104
  *
@@ -81,6 +114,20 @@ export declare function checkText(text: string, validate?: (text: string) => str
81
114
  ok: boolean;
82
115
  reason: string;
83
116
  };
117
+ /**
118
+ * A field's element ids. A one-field dialog's are `text-dialog-input` and `text-dialog-error`;
119
+ * with several, each is named: `text-dialog-<name>` and `text-dialog-<name>-error`. Pure.
120
+ *
121
+ * @param {string} name - The field's name.
122
+ * @param {boolean} only - Whether it's the dialog's only field.
123
+ * @returns {{input: string, error: string}} The ids.
124
+ * @example
125
+ * fieldIds('folder', false); // → {input: 'text-dialog-folder', error: 'text-dialog-folder-error'}
126
+ */
127
+ export declare function fieldIds(name: string, only: boolean): {
128
+ input: string;
129
+ error: string;
130
+ };
84
131
  export type Parts = {
85
132
  /**
86
133
  * The `<dialog>`.
@@ -95,17 +142,9 @@ export type Parts = {
95
142
  */
96
143
  title: HTMLElement;
97
144
  /**
98
- * The field's label.
99
- */
100
- label: HTMLLabelElement;
101
- /**
102
- * The field.
103
- */
104
- input: HTMLInputElement;
105
- /**
106
- * The reason under the field.
145
+ * Where the fields go.
107
146
  */
108
- error: HTMLElement;
147
+ body: HTMLElement;
109
148
  /**
110
149
  * The cancel button.
111
150
  */
@@ -115,8 +154,39 @@ export type Parts = {
115
154
  */
116
155
  confirm: HTMLButtonElement;
117
156
  };
157
+ export type Field = {
158
+ /**
159
+ * What was asked.
160
+ */
161
+ request: FieldRequest;
162
+ /**
163
+ * Its input.
164
+ */
165
+ input: HTMLInputElement;
166
+ /**
167
+ * The reason under it.
168
+ */
169
+ error: HTMLElement;
170
+ };
171
+ /**
172
+ * Ask for several pieces of text in the page's dialog, one field each.
173
+ *
174
+ * @param {FieldsRequest} request - What to ask.
175
+ * @returns {Promise<Record<string, string> | null>} Each field's text by its name, trimmed; null
176
+ * when cancelled.
177
+ * @example
178
+ * const rule = await askFields({
179
+ * title: 'Add folder rule', action: 'Add',
180
+ * fields: [
181
+ * {name: 'from', label: 'Downloads from', placeholder: 'example.com or .pdf', validate: checkFrom},
182
+ * {name: 'folder', label: 'Folder', placeholder: 'Receipts'},
183
+ * ],
184
+ * });
185
+ * if (rule) addRule(rule.from, rule.folder);
186
+ */
187
+ export declare function askFields({ title, fields: requests, action, cancel, returnTo, doc, }: FieldsRequest): Promise<Record<string, string> | null>;
118
188
  /**
119
- * Ask for a piece of text in the page's one-field dialog.
189
+ * Ask for a piece of text in the page's dialog, with one field.
120
190
  *
121
191
  * @param {TextRequest} request - What to ask.
122
192
  * @returns {Promise<string | null>} The text, trimmed; null when cancelled.
@@ -129,4 +199,4 @@ export type Parts = {
129
199
  * validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
130
200
  * });
131
201
  */
132
- export declare function askText({ title, label, value, action, cancel, validate, maxLength, returnTo, doc, }: TextRequest): Promise<string | null>;
202
+ export declare function askText({ label, value, placeholder, validate, maxLength, ...dialog }: TextRequest): Promise<string | null>;
@@ -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
+ }