@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 +24 -4
- package/package.json +1 -1
- package/src/index.d.ts +5 -1
- package/src/index.js +3 -1
- package/src/ui/text-dialog.d.ts +107 -37
- package/src/ui/text-dialog.js +167 -72
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
|
|
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
|
-
###
|
|
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` (
|
|
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.
|
|
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
|
*/
|
package/src/ui/text-dialog.d.ts
CHANGED
|
@@ -1,43 +1,55 @@
|
|
|
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
|
*/
|
|
11
|
-
export type
|
|
13
|
+
export type FieldRequest = {
|
|
12
14
|
/**
|
|
13
|
-
*
|
|
15
|
+
* Its key in the answer ("folder"), and part of its id.
|
|
14
16
|
*/
|
|
15
|
-
|
|
17
|
+
name: string;
|
|
16
18
|
/**
|
|
17
|
-
*
|
|
19
|
+
* Its label ("Folder").
|
|
18
20
|
*/
|
|
19
21
|
label: string;
|
|
20
22
|
/**
|
|
21
|
-
* The text to start with
|
|
23
|
+
* The text to start with. Default ''.
|
|
22
24
|
*/
|
|
23
25
|
value?: string;
|
|
24
26
|
/**
|
|
25
|
-
*
|
|
26
|
-
*/
|
|
27
|
-
action?: string;
|
|
28
|
-
/**
|
|
29
|
-
* The cancel button's text. Default "Cancel".
|
|
27
|
+
* Shown while it's empty. Default none.
|
|
30
28
|
*/
|
|
31
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
69
|
+
* One field of the dialog.
|
|
54
70
|
*
|
|
55
|
-
* @typedef {object}
|
|
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
|
-
*
|
|
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
|
-
|
|
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
|
|
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({
|
|
202
|
+
export declare function askText({ label, value, placeholder, validate, maxLength, ...dialog }: TextRequest): Promise<string | null>;
|
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
|
+
}
|